From e24daf54c16cd8f31c74c49a033502add2db0c67 Mon Sep 17 00:00:00 2001 From: HumbleTurtle Date: Tue, 19 May 2026 17:42:44 -0300 Subject: [PATCH] Simplify chat secret CLI flow --- resources/scripts/nap_install.py | 18 ++- resources/scripts/napseer_mcp_server.py | 184 +++++++++++++++++++++- scripts/nap_install.py | 18 ++- tests/test_chat_secret_cli_flow.py | 70 ++++++++ tests/test_gateway_vault_setup_payload.py | 21 ++- tests/test_nap_install_gateway_routes.py | 10 +- 6 files changed, 297 insertions(+), 24 deletions(-) create mode 100644 tests/test_chat_secret_cli_flow.py diff --git a/resources/scripts/nap_install.py b/resources/scripts/nap_install.py index 0242d08..50a8688 100644 --- a/resources/scripts/nap_install.py +++ b/resources/scripts/nap_install.py @@ -598,14 +598,18 @@ def handle_gateway(args): print(" nap gateway setup Create or migrate local gateway storage.") print(" nap gateway vault Show gateway vault status and pending setup requests.") print(" nap gateway vault process Complete pending setup requests.") - print(" nap gateway vault rotate-secret --kind chat|tabs|gateway|memory") + print(" nap gateway vault rotate-secret --kind memory") + print(" nap chat secret setup Configure chat secret state through the gateway.") return 0 if subcommand == "vault" and rest and rest[0] in {"help", "-h", "--help"}: - print("Usage: nap gateway vault [status|list|process|rotate-secret] [--kind chat|tabs|gateway|memory] [--all] [--project-id ID]") + print("Usage: nap gateway vault [status|list|process|rotate-secret] [--kind memory] [--all] [--project-id ID]") print(" status Show local gateway state and pending setup requests.") print(" process Upload opaque client-wrapped key bundle records for backend-owned HashiCorp storage.") - print(" rotate-secret Rotate one encrypted project secret.") + print(" rotate-secret Rotate the memory project secret from the gateway worker path.") + print(" Chat secrets use: nap chat secret setup|status|rotate") return 0 + if subcommand == "process": + return run_wrapper("gateway", ["vault", "process", *rest]) if subcommand == "start": return print_json(start_service("gateway", ["--no-browser", *rest])) or 0 if subcommand == "stop": @@ -638,6 +642,8 @@ def main(argv): return 0 if command == "gateway": return handle_gateway(args) + if command == "chat": + return run_wrapper("chat", args or ["secret", "status"]) if command == "status": return run_wrapper("configure", args) if command == "project": @@ -674,19 +680,21 @@ def main(argv): ) return 0 if command in {"help", "-h", "--help"}: - print("Usage: nap [auth|project|plan|lineage|gateway|agent|feedback|status|update|where|install]") + 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(" 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, nap chat secret status") print(" plan Plan commands: list, to-kanban.") print(" Example: nap plan to-kanban /plans/example --column todo") print(" lineage Generic source-record -> plan -> kanban lineage checks.") print(" Example: nap lineage status") print(" gateway Gateway commands: start, stop, configure, setup, restart, kill, logs, status, vault.") print(" Start auto-selects a local port; use --port to pin one.") - print(" Examples: nap gateway start, nap gateway vault, nap gateway vault rotate-secret --kind chat") + print(" Examples: nap gateway start, nap gateway vault, nap gateway vault process --all") print(" agent Agent commands: list, workspaces, create, show, edit.") print(" list shows registered project agents; workspaces shows /agents folders.") print(" feedback Feedback commands: list, global, status, resolve.") diff --git a/resources/scripts/napseer_mcp_server.py b/resources/scripts/napseer_mcp_server.py index 8631fd0..e962df9 100755 --- a/resources/scripts/napseer_mcp_server.py +++ b/resources/scripts/napseer_mcp_server.py @@ -3874,6 +3874,12 @@ def gateway_rotate_project_vault_secret(args=None): secret_kind = str(args.get("secret_kind") or args.get("kind") or "chat").strip().lower() if secret_kind not in GATEWAY_PROJECT_SECRET_KINDS: raise RuntimeError("secret_kind must be chat, tabs, gateway, or memory") + if secret_kind != MEMORY_SECRET_KIND: + raise RuntimeError( + f"{secret_kind} secret rotation requires operator authorization and is not available " + "through the gateway worker token. Use `nap chat secret setup` to initialize chat " + "secret state, and rotate chat secrets from an operator-authorized session." + ) current = request_json("GET", f"/v1/projects/{project_id}/vault/secrets/{urllib.parse.quote(secret_kind, safe='')}/active") if current.get("secret_kind") != secret_kind: raise RuntimeError("active project secret is unavailable") @@ -3931,6 +3937,110 @@ def gateway_rotate_project_vault_secret(args=None): return result +def project_secret_versions(project_id, secret_kind=None): + path = f"/v1/projects/{project_id}/vault/secrets" + if secret_kind: + path = f"{path}?secret_kind={urllib.parse.quote(str(secret_kind), safe='')}" + return request_json("GET", path) + + +def chat_secret_status(args=None): + args = args or {} + project_id = str(args.get("project_id") or current_project_id()).strip() + vault = gateway_vault_status({"project_id": project_id}) + response = { + "status": "ok", + "project_id": project_id, + "locked": vault.get("locked"), + "vault_configured": vault.get("vault_configured"), + "pending_setup_requests": vault.get("pending_setup_requests", 0), + "chat_secret_configured": None, + "active_chat_secret": None, + } + try: + versions = project_secret_versions(project_id, "chat").get("items", []) + except Exception as exc: + return { + **response, + "status": "operator_auth_required", + "message": ( + "Could not read chat secret status with the current token. " + "Chat/tabs/gateway secret status and rotation require operator authorization. " + "Run `nap auth login-local`, then retry `nap chat secret status`." + ), + "error": str(exc), + } + active = next( + ( + item for item in versions + if item.get("secret_kind") == "chat" + and item.get("status") == "active" + and item.get("activation_status") == "active" + ), + None, + ) + if active: + return { + **response, + "chat_secret_configured": True, + "active_chat_secret": { + "version": active.get("version"), + "last_rotated_at": active.get("last_rotated_at"), + "storage_provider": active.get("storage_provider"), + }, + "message": "Chat secret is configured.", + } + if response["pending_setup_requests"]: + return { + **response, + "chat_secret_configured": False, + "message": "Chat secret setup is pending. Run `nap chat secret setup`.", + } + return { + **response, + "chat_secret_configured": False, + "message": ( + "No active chat secret and no pending gateway vault setup request. " + "Enable encrypted project setup from the cloud UI, then run `nap chat secret setup`." + ), + } + + +def chat_secret_setup(args=None): + args = args or {} + project_id = str(args.get("project_id") or current_project_id()).strip() + status = chat_secret_status({"project_id": project_id}) + if status.get("chat_secret_configured") is True: + return status + if status.get("locked"): + return { + **status, + "status": "locked", + "message": "Gateway vault is locked. Run `nap gateway setup` or unlock the gateway, then retry `nap chat secret setup`.", + } + if status.get("pending_setup_requests", 0) > 0: + processed = gateway_process_vault_setup_requests({"project_id": project_id, "complete_all": True}) + return { + **chat_secret_status({"project_id": project_id}), + "processed_setup": processed, + } + return status + + +def chat_secret_rotate(args=None): + args = args or {} + project_id = str(args.get("project_id") or current_project_id()).strip() + return { + "status": "operator_auth_required", + "project_id": project_id, + "message": ( + "Chat secret rotation is not available through the gateway worker token. " + "This preserves the boundary that worker tokens cannot read active chat secrets. " + "Use an operator-authorized rotation flow." + ), + } + + def gateway_vault_setup_loop(): interval = max(10, min(int(os.environ.get("NAPSEER_GATEWAY_VAULT_SETUP_POLL_SECONDS", "30")), 300)) gateway_log("gateway_vault_setup_thread_started", project_id=current_project_id()) @@ -10273,17 +10383,44 @@ def raw_tools(): }, { "name": "nap_gateway_vault_secret_rotate", - "description": "Rotate one project secret version from this local gateway by uploading an opaque client-wrapped key bundle record for backend-owned HashiCorp storage.", + "description": "Rotate the memory project secret version from this local gateway by uploading an opaque client-wrapped key bundle record for backend-owned HashiCorp storage. Chat, tabs, and gateway secrets require operator authorization.", "inputSchema": { "type": "object", "required": ["secret_kind"], "properties": { "project_id": {"type": "string"}, - "secret_kind": {"type": "string", "enum": ["chat", "tabs", "gateway", "memory"]}, + "secret_kind": {"type": "string", "enum": ["memory"]}, }, "additionalProperties": False, }, }, + { + "name": "nap_chat_secret_status", + "description": "Show chat secret setup status without exposing secret material.", + "inputSchema": { + "type": "object", + "properties": {"project_id": {"type": "string"}}, + "additionalProperties": False, + }, + }, + { + "name": "nap_chat_secret_setup", + "description": "Complete pending gateway vault setup needed for chat secrets, or report the next required action.", + "inputSchema": { + "type": "object", + "properties": {"project_id": {"type": "string"}}, + "additionalProperties": False, + }, + }, + { + "name": "nap_chat_secret_rotate", + "description": "Explain the operator authorization required to rotate chat secrets.", + "inputSchema": { + "type": "object", + "properties": {"project_id": {"type": "string"}}, + "additionalProperties": False, + }, + }, { "name": "nap_gateway_restart", "description": "Kill and relaunch the managed gateway tmux window using the configured default command.", @@ -11238,6 +11375,9 @@ TOOL_CATEGORIES = { "nap_gateway_vault_ls", "nap_gateway_vault_process", "nap_gateway_vault_secret_rotate", + "nap_chat_secret_status", + "nap_chat_secret_setup", + "nap_chat_secret_rotate", "nap_gateway_restart", "nap_gateway_kill", "nap_ui_open", @@ -11266,6 +11406,7 @@ def tool_category(name): READ_ONLY_TOOLS = { "nap_whoami", "nap_gateway_status", + "nap_chat_secret_status", "nap_update_status", "nap_apropos", "nap_man", @@ -11312,6 +11453,9 @@ LOCAL_FILE_TOOLS = { "nap_gateway_configure", "nap_gateway_vault_process", "nap_gateway_vault_secret_rotate", + "nap_chat_secret_status", + "nap_chat_secret_setup", + "nap_chat_secret_rotate", } PROCESS_CONTROL_TOOLS = {"nap_ui_open"} @@ -11587,6 +11731,12 @@ def call_tool_impl(name, args): return gateway_process_vault_setup_requests(args) if name == "nap_gateway_vault_secret_rotate": return gateway_rotate_project_vault_secret(args) + if name == "nap_chat_secret_status": + return chat_secret_status(args) + if name == "nap_chat_secret_setup": + return chat_secret_setup(args) + if name == "nap_chat_secret_rotate": + return chat_secret_rotate(args) if name == "nap_gateway_restart": return gateway_terminal_restart(args) if name == "nap_gateway_kill": @@ -12092,7 +12242,8 @@ def cli_main(argv): action = args[0] if args else "status" vault_args = args[1:] if args else [] if action in {"help", "-h", "--help"}: - print("Usage: napseer_mcp_server.py gateway vault [status|list|process|rotate-secret] [--kind chat|tabs|gateway|memory] [--all] [--project-id ID]") + print("Usage: napseer_mcp_server.py gateway vault [status|list|process|rotate-secret] [--kind memory] [--all] [--project-id ID]") + print("Chat secrets use: nap chat secret setup|status|rotate") return payload = { "project_id": cli_option(vault_args, "--project-id", default=None), @@ -12107,7 +12258,7 @@ def cli_main(argv): return if action == "rotate-secret": if not payload.get("secret_kind"): - payload["secret_kind"] = vault_args[0] if vault_args and not vault_args[0].startswith("-") else "chat" + payload["secret_kind"] = vault_args[0] if vault_args and not vault_args[0].startswith("-") else "memory" print(json.dumps(gateway_rotate_project_vault_secret(payload), indent=2)) return raise RuntimeError("unknown gateway vault action; use status|list|process|rotate-secret") @@ -12122,7 +12273,30 @@ def cli_main(argv): "new_passphrase": cli_option(args, "--new-passphrase", "--passphrase", default=None), }), indent=2)) return - print("Usage: napseer_mcp_server.py gateway [status|configure [--command CMD] [--target session:window.pane]|vault [status|list|process|rotate-secret] [--kind chat|tabs|gateway|memory] [--all]|terminal [list|create|close|capture|input]|schedule [list|create|update|delete|run]|restart|kill|setup|rotate-passphrase --new-passphrase TEXT]") + print("Usage: napseer_mcp_server.py gateway [status|configure [--command CMD] [--target session:window.pane]|vault [status|list|process|rotate-secret] [--kind memory] [--all]|terminal [list|create|close|capture|input]|schedule [list|create|update|delete|run]|restart|kill|setup|rotate-passphrase --new-passphrase TEXT]") + return + if command == "chat": + subcommand = argv[2] if len(argv) > 2 else "secret" + args = argv[3:] + if subcommand == "secret": + action = args[0] if args else "status" + secret_args = args[1:] if args else [] + else: + action = subcommand + secret_args = args + payload = { + "project_id": cli_option(secret_args, "--project-id", default=None), + } + if action in {"status", "show", "ls", "list"}: + print(json.dumps(chat_secret_status(payload), indent=2)) + return + if action in {"setup", "configure", "init"}: + print(json.dumps(chat_secret_setup(payload), indent=2)) + return + if action in {"rotate", "rotate-secret"}: + print(json.dumps(chat_secret_rotate(payload), indent=2)) + return + print("Usage: napseer_mcp_server.py chat secret [status|setup|rotate] [--project-id ID]") return if command == "feedback": subcommand = argv[2] if len(argv) > 2 else "list" diff --git a/scripts/nap_install.py b/scripts/nap_install.py index 0242d08..50a8688 100644 --- a/scripts/nap_install.py +++ b/scripts/nap_install.py @@ -598,14 +598,18 @@ def handle_gateway(args): print(" nap gateway setup Create or migrate local gateway storage.") print(" nap gateway vault Show gateway vault status and pending setup requests.") print(" nap gateway vault process Complete pending setup requests.") - print(" nap gateway vault rotate-secret --kind chat|tabs|gateway|memory") + print(" nap gateway vault rotate-secret --kind memory") + print(" nap chat secret setup Configure chat secret state through the gateway.") return 0 if subcommand == "vault" and rest and rest[0] in {"help", "-h", "--help"}: - print("Usage: nap gateway vault [status|list|process|rotate-secret] [--kind chat|tabs|gateway|memory] [--all] [--project-id ID]") + print("Usage: nap gateway vault [status|list|process|rotate-secret] [--kind memory] [--all] [--project-id ID]") print(" status Show local gateway state and pending setup requests.") print(" process Upload opaque client-wrapped key bundle records for backend-owned HashiCorp storage.") - print(" rotate-secret Rotate one encrypted project secret.") + print(" rotate-secret Rotate the memory project secret from the gateway worker path.") + print(" Chat secrets use: nap chat secret setup|status|rotate") return 0 + if subcommand == "process": + return run_wrapper("gateway", ["vault", "process", *rest]) if subcommand == "start": return print_json(start_service("gateway", ["--no-browser", *rest])) or 0 if subcommand == "stop": @@ -638,6 +642,8 @@ def main(argv): return 0 if command == "gateway": return handle_gateway(args) + if command == "chat": + return run_wrapper("chat", args or ["secret", "status"]) if command == "status": return run_wrapper("configure", args) if command == "project": @@ -674,19 +680,21 @@ def main(argv): ) return 0 if command in {"help", "-h", "--help"}: - print("Usage: nap [auth|project|plan|lineage|gateway|agent|feedback|status|update|where|install]") + 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(" 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, nap chat secret status") print(" plan Plan commands: list, to-kanban.") print(" Example: nap plan to-kanban /plans/example --column todo") print(" lineage Generic source-record -> plan -> kanban lineage checks.") print(" Example: nap lineage status") print(" gateway Gateway commands: start, stop, configure, setup, restart, kill, logs, status, vault.") print(" Start auto-selects a local port; use --port to pin one.") - print(" Examples: nap gateway start, nap gateway vault, nap gateway vault rotate-secret --kind chat") + print(" Examples: nap gateway start, nap gateway vault, nap gateway vault process --all") print(" agent Agent commands: list, workspaces, create, show, edit.") print(" list shows registered project agents; workspaces shows /agents folders.") print(" feedback Feedback commands: list, global, status, resolve.") diff --git a/tests/test_chat_secret_cli_flow.py b/tests/test_chat_secret_cli_flow.py new file mode 100644 index 0000000..01b7960 --- /dev/null +++ b/tests/test_chat_secret_cli_flow.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Smoke-test simplified chat secret CLI flow.""" + +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_chat_secret_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() + processed = [] + + mod.current_project_id = lambda: PROJECT_ID + mod.gateway_is_unlocked = lambda: True + mod.vault_exists = lambda: True + mod.gateway_vault_setup_requests = lambda args=None: { + "items": [{"id": "setup-1", "status": "pending", "project_id": PROJECT_ID}] + } + mod.gateway_process_vault_setup_requests = lambda args=None: processed.append(args) or { + "status": "processed", + "completed_count": 1, + } + + def request_json(method, path, payload=None, **kwargs): + assert method == "GET" + assert path == f"/v1/projects/{PROJECT_ID}/vault/secrets?secret_kind=chat" + return {"items": []} + + mod.request_json = request_json + setup = mod.chat_secret_setup({"project_id": PROJECT_ID}) + assert processed and processed[-1]["complete_all"] is True + assert setup["chat_secret_configured"] is False + + mod.gateway_vault_setup_requests = lambda args=None: {"items": []} + mod.request_json = lambda method, path, payload=None, **kwargs: { + "items": [{ + "secret_kind": "chat", + "version": 3, + "status": "active", + "activation_status": "active", + "storage_provider": "hashicorp_vault_kv2", + "last_rotated_at": "2026-05-19T20:00:00Z", + }] + } + status = mod.chat_secret_status({"project_id": PROJECT_ID}) + assert status["chat_secret_configured"] is True + assert status["active_chat_secret"]["version"] == 3 + + rotation = mod.chat_secret_rotate({"project_id": PROJECT_ID}) + assert rotation["status"] == "operator_auth_required" + assert "worker token" in rotation["message"] + + print("ok: chat secret CLI flow smoke passed") + + +if __name__ == "__main__": + run() diff --git a/tests/test_gateway_vault_setup_payload.py b/tests/test_gateway_vault_setup_payload.py index 416ab0b..a011d28 100644 --- a/tests/test_gateway_vault_setup_payload.py +++ b/tests/test_gateway_vault_setup_payload.py @@ -139,7 +139,7 @@ def run(): active_bundle = mod.wrapped_project_key_bundle_record( PROJECT_ID, ACCOUNT_ID, - "chat", + "memory", mod.generate_project_data_key_bundle(PROJECT_ID, ACCOUNT_ID, data_key_epoch=1), wrapping_epoch=1, bundle_version=1, @@ -147,9 +147,9 @@ def run(): ) def request_json(method, path, payload=None, **kwargs): - assert (method, path) == ("GET", f"/v1/projects/{PROJECT_ID}/vault/secrets/chat/active") + assert (method, path) == ("GET", f"/v1/projects/{PROJECT_ID}/vault/secrets/memory/active") return { - "secret_kind": "chat", + "secret_kind": "memory", "version": 1, "wrapping_epoch": 1, "bundle_version": 1, @@ -165,13 +165,20 @@ def run(): ) or { "id": "secret-version-2", "version": 2, - "secret_kind": "chat", + "secret_kind": "memory", } - rotated = mod.gateway_rotate_project_vault_secret({"project_id": PROJECT_ID, "secret_kind": "chat"}) + try: + mod.gateway_rotate_project_vault_secret({"project_id": PROJECT_ID, "secret_kind": "chat"}) + except RuntimeError as exc: + assert "requires operator authorization" in str(exc) + else: + raise AssertionError("chat rotation must not run through the gateway worker path") + + rotated = mod.gateway_rotate_project_vault_secret({"project_id": PROJECT_ID, "secret_kind": "memory"}) assert rotated["version"] == 2 assert not writes, "rotation must not persist project secret material locally" assert calls[0][0] == "POST" - assert calls[0][1] == f"/v1/projects/{PROJECT_ID}/vault/secrets/chat/rotate" + assert calls[0][1] == f"/v1/projects/{PROJECT_ID}/vault/secrets/memory/rotate" assert calls[0][5] == "encryption" assert set(calls[0][2]) == { "previous_wrapping_epoch", @@ -186,7 +193,7 @@ def run(): assert calls[0][2]["previous_data_key_epoch"] == 1 assert calls[0][2]["previous_aad_hash"] == active_bundle["aad_hash"] assert calls[0][2]["previous_ciphertext_sha256"] == active_bundle["ciphertext_sha256"] - assert_wrapped_record(mod, calls[0][2]["wrapped_key_bundle"], "chat", data_key_epoch=2) + assert_wrapped_record(mod, calls[0][2]["wrapped_key_bundle"], "memory", data_key_epoch=2) assert "secret_b64" not in str(calls[0][2]) assert mod.VAULT_SECRETS == local_before diff --git a/tests/test_nap_install_gateway_routes.py b/tests/test_nap_install_gateway_routes.py index 434b4fe..c0e8017 100644 --- a/tests/test_nap_install_gateway_routes.py +++ b/tests/test_nap_install_gateway_routes.py @@ -30,8 +30,14 @@ def run(): assert mod.handle_gateway(["vault", "list"]) == 0 assert calls[-1] == ("gateway", ["vault", "list"]) - assert mod.handle_gateway(["vault", "rotate-secret", "--kind", "chat"]) == 0 - assert calls[-1] == ("gateway", ["vault", "rotate-secret", "--kind", "chat"]) + assert mod.handle_gateway(["vault", "rotate-secret", "--kind", "memory"]) == 0 + assert calls[-1] == ("gateway", ["vault", "rotate-secret", "--kind", "memory"]) + + assert mod.handle_gateway(["process", "--all"]) == 0 + assert calls[-1] == ("gateway", ["vault", "process", "--all"]) + + assert mod.main(["nap", "chat", "secret", "setup"]) == 0 + assert calls[-1] == ("chat", ["secret", "setup"]) try: mod.handle_gateway(["config"])