Remove local gateway unlock gate

This commit is contained in:
HumbleTurtle
2026-05-10 07:54:34 -03:00
parent e7aed9ddf6
commit dd0af0f6a9
7 changed files with 248 additions and 149 deletions
+7 -7
View File
@@ -591,18 +591,18 @@ def handle_gateway(args):
subcommand = args[0] if args else "ls" subcommand = args[0] if args else "ls"
rest = args[1:] if args else [] rest = args[1:] if args else []
if subcommand in {"help", "-h", "--help"}: if subcommand in {"help", "-h", "--help"}:
print("Usage: nap gateway [start|stop|status|logs|configure|setup|unlock|lock|restart|kill|vault|terminal|schedule|service]") print("Usage: nap gateway [start|stop|status|logs|configure|setup|restart|kill|vault|terminal|schedule|service]")
print(" nap gateway start [--port PORT] Start the local gateway service.") print(" nap gateway start [--port PORT] Start the local gateway service.")
print(" nap gateway status Show the managed gateway process.") print(" nap gateway status Show the managed gateway process.")
print(" nap gateway configure [--command CMD] Configure the local gateway command.") print(" nap gateway configure [--command CMD] Configure the local gateway command.")
print(" nap gateway setup Create or unlock the encrypted gateway vault.") 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 Show gateway vault status and pending setup requests.")
print(" nap gateway vault process Complete 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 chat|tabs|gateway|memory")
return 0 return 0
if subcommand == "vault" and rest and rest[0] in {"help", "-h", "--help"}: if subcommand == "vault" and rest and rest[0] in {"help", "-h", "--help"}:
print("Usage: nap gateway vault [status|list|process|rotate-secret] [--kind chat|tabs|gateway|memory] [--all] [--project-id ID]") print("Usage: nap gateway vault [status|list|process|rotate-secret] [--kind chat|tabs|gateway|memory] [--all] [--project-id ID]")
print(" status Show configured/locked state and pending setup requests.") print(" status Show local gateway state and pending setup requests.")
print(" process Upload opaque client-wrapped key bundle records for backend-owned HashiCorp storage.") print(" process Upload opaque client-wrapped key bundle records for backend-owned HashiCorp storage.")
print(" rotate-secret Rotate one encrypted project secret.") print(" rotate-secret Rotate one encrypted project secret.")
return 0 return 0
@@ -616,9 +616,9 @@ def handle_gateway(args):
return print_json(service_logs("gateway", rest)) or 0 return print_json(service_logs("gateway", rest)) or 0
if subcommand == "configure": if subcommand == "configure":
return run_wrapper("gateway", ["configure", *rest]) return run_wrapper("gateway", ["configure", *rest])
if subcommand in {"setup", "unlock", "lock", "restart", "kill", "vault", "terminal", "schedule", "service"}: if subcommand in {"setup", "restart", "kill", "vault", "terminal", "schedule", "service"}:
return run_wrapper("gateway", [subcommand, *rest]) return run_wrapper("gateway", [subcommand, *rest])
raise RuntimeError("unknown gateway command; use nap gateway start|stop|configure|setup|unlock|lock|restart|kill|logs|status|vault|terminal|schedule|service") raise RuntimeError("unknown gateway command; use nap gateway start|stop|configure|setup|restart|kill|logs|status|vault|terminal|schedule|service")
def print_json(value): def print_json(value):
@@ -675,8 +675,8 @@ def main(argv):
print(" Example: nap auth login-local") print(" Example: nap auth login-local")
print(" project Project commands: create, status, encryption.") print(" project Project commands: create, status, encryption.")
print(" Examples: nap project create, nap project status, nap project encryption status") print(" Examples: nap project create, nap project status, nap project encryption status")
print(" Encrypted project setup is completed by an unlocked gateway: nap gateway vault process") print(" Encrypted project setup is completed by the local gateway: nap gateway vault process")
print(" gateway Gateway commands: start, stop, configure, setup, unlock, lock, restart, kill, logs, status, vault.") 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(" 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 rotate-secret --kind chat")
print(" agent Agent commands: list, workspaces, create, show, edit.") print(" agent Agent commands: list, workspaces, create, show, edit.")
+133 -98
View File
@@ -293,6 +293,7 @@ def send_telemetry_event_async(event, component, outcome="success", **fields):
AUTH_DIR = local_state_dir() AUTH_DIR = local_state_dir()
AUTH_PATH = AUTH_DIR / "auth.json" AUTH_PATH = AUTH_DIR / "auth.json"
VAULT_PATH = AUTH_DIR / "vault.json" VAULT_PATH = AUTH_DIR / "vault.json"
LOCAL_VAULT_KEY_PATH = AUTH_DIR / "vault.local-key"
GATEWAY_RELAY_SECRET_PATH = AUTH_DIR / "gateway-relay-secret.json" GATEWAY_RELAY_SECRET_PATH = AUTH_DIR / "gateway-relay-secret.json"
GATEWAY_RELAY_STATE_PATH = AUTH_DIR / "gateway-relay-state.json" GATEWAY_RELAY_STATE_PATH = AUTH_DIR / "gateway-relay-state.json"
GATEWAY_SCHEDULES_PATH = AUTH_DIR / "gateway-schedules.json" GATEWAY_SCHEDULES_PATH = AUTH_DIR / "gateway-schedules.json"
@@ -440,8 +441,6 @@ PUBLIC_TOOLS = {
"nap_whoami", "nap_whoami",
"nap_gateway_status", "nap_gateway_status",
"nap_gateway_setup", "nap_gateway_setup",
"nap_gateway_unlock",
"nap_gateway_lock",
"nap_gateway_configure", "nap_gateway_configure",
"nap_gateway_vault_ls", "nap_gateway_vault_ls",
"nap_gateway_vault_process", "nap_gateway_vault_process",
@@ -696,13 +695,13 @@ def _decrypt_vault_payload(key, nonce, ciphertext, tag, aad=b""):
one_time_key = _chacha20_block(key, 0, nonce)[:32] one_time_key = _chacha20_block(key, 0, nonce)[:32]
expected = _poly1305_mac(_aead_mac_data(aad, ciphertext), one_time_key) expected = _poly1305_mac(_aead_mac_data(aad, ciphertext), one_time_key)
if not hmac.compare_digest(expected, tag): if not hmac.compare_digest(expected, tag):
raise ValueError("gateway unlock failed") raise ValueError("local gateway vault open failed")
return _chacha20_xor(key, nonce, ciphertext) return _chacha20_xor(key, nonce, ciphertext)
def _derive_vault_key(passphrase, salt, iterations): def _derive_vault_key(passphrase, salt, iterations):
if not isinstance(passphrase, str) or not passphrase: if not isinstance(passphrase, str) or not passphrase:
raise ValueError("master passphrase is required") raise ValueError("local gateway vault key is required")
return hashlib.pbkdf2_hmac("sha256", passphrase.encode("utf-8"), salt, iterations, dklen=32) return hashlib.pbkdf2_hmac("sha256", passphrase.encode("utf-8"), salt, iterations, dklen=32)
@@ -712,14 +711,32 @@ def read_master_passphrase(create=False):
return value return value
if not sys.stdin.isatty(): if not sys.stdin.isatty():
raise RuntimeError("NAPSEER_MASTER_PASSPHRASE is required in non-interactive mode") raise RuntimeError("NAPSEER_MASTER_PASSPHRASE is required in non-interactive mode")
first = getpass.getpass("Create master passphrase: " if create else "Master passphrase: ") first = getpass.getpass("Create gateway relay passphrase: " if create else "Gateway relay passphrase: ")
if create: if create:
second = getpass.getpass("Confirm master passphrase: ") second = getpass.getpass("Confirm gateway relay passphrase: ")
if first != second: if first != second:
raise RuntimeError("master passphrases did not match") raise RuntimeError("gateway relay passphrases did not match")
return first return first
def read_local_vault_key(create=False):
value = os.environ.get("NAPSEER_LOCAL_VAULT_KEY")
if value:
return value
if LOCAL_VAULT_KEY_PATH.exists():
value = LOCAL_VAULT_KEY_PATH.read_text(encoding="utf-8").strip()
if value:
return value
if not create:
return None
AUTH_DIR.mkdir(exist_ok=True)
chmod_best_effort(AUTH_DIR, 0o700)
value = base64.b64encode(secrets.token_bytes(32)).decode("ascii")
LOCAL_VAULT_KEY_PATH.write_text(value + "\n", encoding="utf-8")
chmod_best_effort(LOCAL_VAULT_KEY_PATH, 0o600)
return value
def vault_exists(): def vault_exists():
return VAULT_PATH.exists() return VAULT_PATH.exists()
@@ -750,13 +767,13 @@ def write_public_auth(data):
def read_vault(passphrase, return_key=False): def read_vault(passphrase, return_key=False):
envelope = json.loads(VAULT_PATH.read_text(encoding="utf-8")) envelope = json.loads(VAULT_PATH.read_text(encoding="utf-8"))
if envelope.get("schema") != VAULT_SCHEMA: if envelope.get("schema") != VAULT_SCHEMA:
raise ValueError("gateway unlock failed") raise ValueError("local gateway vault open failed")
if envelope.get("format") == "plaintext-local" or "payload" in envelope: if envelope.get("format") == "plaintext-local" or "payload" in envelope:
raise ValueError("plaintext local gateway vault is unsupported; rerun gateway setup with a passphrase") raise ValueError("plaintext local gateway vault is unsupported; rerun gateway setup with a passphrase")
kdf = envelope.get("kdf") or {} kdf = envelope.get("kdf") or {}
cipher = envelope.get("cipher") or {} cipher = envelope.get("cipher") or {}
if kdf.get("name") != "pbkdf2-sha256" or cipher.get("name") != "chacha20-poly1305": if kdf.get("name") != "pbkdf2-sha256" or cipher.get("name") != "chacha20-poly1305":
raise ValueError("gateway unlock failed") raise ValueError("local gateway vault open failed")
salt = base64.b64decode(kdf["salt"]) salt = base64.b64decode(kdf["salt"])
iterations = int(kdf["iterations"]) iterations = int(kdf["iterations"])
nonce = base64.b64decode(cipher["nonce"]) nonce = base64.b64decode(cipher["nonce"])
@@ -766,7 +783,7 @@ def read_vault(passphrase, return_key=False):
plaintext = _decrypt_vault_payload(key, nonce, ciphertext, tag, VAULT_SCHEMA.encode("utf-8")) plaintext = _decrypt_vault_payload(key, nonce, ciphertext, tag, VAULT_SCHEMA.encode("utf-8"))
data = json.loads(plaintext.decode("utf-8")) data = json.loads(plaintext.decode("utf-8"))
if not isinstance(data, dict): if not isinstance(data, dict):
raise ValueError("gateway unlock failed") raise ValueError("local gateway vault open failed")
return (data, key) if return_key else data return (data, key) if return_key else data
@@ -804,7 +821,7 @@ def write_vault(passphrase, payload):
def write_vault_with_key(key, payload): def write_vault_with_key(key, payload):
if not key: if not key:
raise RuntimeError("gateway is locked; unlock before updating vault secrets") raise RuntimeError("local gateway vault key is unavailable before updating vault secrets")
plaintext = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") plaintext = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
nonce, ciphertext, tag = _encrypt_vault_payload(key, plaintext, VAULT_SCHEMA.encode("utf-8")) nonce, ciphertext, tag = _encrypt_vault_payload(key, plaintext, VAULT_SCHEMA.encode("utf-8"))
envelope = json.loads(VAULT_PATH.read_text(encoding="utf-8")) envelope = json.loads(VAULT_PATH.read_text(encoding="utf-8"))
@@ -1049,10 +1066,10 @@ def gateway_locked_reason():
if not vault_exists(): if not vault_exists():
return "vault_not_configured" return "vault_not_configured"
if not GATEWAY_UNLOCKED: if not GATEWAY_UNLOCKED:
return "locked" try:
if GATEWAY_LAST_USED_AT and datetime.now(timezone.utc) - GATEWAY_LAST_USED_AT > timedelta(seconds=GATEWAY_IDLE_TIMEOUT_SECONDS): gateway_open_local()
gateway_lock() except Exception:
return "idle_timeout" return "local_vault_unavailable"
return None return None
@@ -1065,20 +1082,28 @@ def touch_gateway():
GATEWAY_LAST_USED_AT = datetime.now(timezone.utc) GATEWAY_LAST_USED_AT = datetime.now(timezone.utc)
def gateway_lock(): def clear_gateway_runtime_caches():
global VAULT_SECRETS, VAULT_KEY, GATEWAY_UNLOCKED, GATEWAY_LAST_USED_AT, AUTH, TOKEN, TOKEN_EXPIRES_AT, GATEWAY_PROJECT_BUNDLE_PASSPHRASE global GATEWAY_PROJECT_BUNDLE_PASSPHRASE
with GATEWAY_LOCK:
VAULT_SECRETS = {}
VAULT_KEY = None
GATEWAY_PROJECT_BUNDLE_PASSPHRASE = None GATEWAY_PROJECT_BUNDLE_PASSPHRASE = None
MEMORY_SECRET_CACHE.clear() MEMORY_SECRET_CACHE.clear()
ACTIVE_MEMORY_SECRET_VERSIONS.clear() ACTIVE_MEMORY_SECRET_VERSIONS.clear()
def gateway_lock():
global VAULT_SECRETS, VAULT_KEY, GATEWAY_UNLOCKED, GATEWAY_LAST_USED_AT, AUTH, TOKEN, TOKEN_EXPIRES_AT
with GATEWAY_LOCK:
clear_gateway_runtime_caches()
if vault_exists():
gateway_open_local()
else:
VAULT_SECRETS = {}
VAULT_KEY = None
GATEWAY_UNLOCKED = False GATEWAY_UNLOCKED = False
GATEWAY_LAST_USED_AT = None GATEWAY_LAST_USED_AT = None
AUTH = load_auth() AUTH = load_auth()
TOKEN = AUTH["token"] TOKEN = AUTH["token"]
TOKEN_EXPIRES_AT = AUTH["token_expires_at"] TOKEN_EXPIRES_AT = AUTH["token_expires_at"]
return {"status": "locked", "vault_configured": vault_exists()} return {"status": "local_available" if GATEWAY_UNLOCKED else "not_configured", "vault_configured": vault_exists()}
def gateway_status(): def gateway_status():
@@ -1090,7 +1115,7 @@ def gateway_status():
message = "No project/token is configured for this directory." message = "No project/token is configured for this directory."
elif not vault_exists(): elif not vault_exists():
status = "vault_not_configured" status = "vault_not_configured"
message = "Gateway vault is not configured; run nap gateway configure." message = "Local gateway storage is not configured; run nap gateway configure."
elif remote_enabled and listener_ready: elif remote_enabled and listener_ready:
status = "relay_ready" status = "relay_ready"
message = "Gateway relay listener is connected." message = "Gateway relay listener is connected."
@@ -1109,9 +1134,10 @@ def gateway_status():
"container_uuid": read_container_uuid(), "container_uuid": read_container_uuid(),
"vault_path": str(VAULT_PATH), "vault_path": str(VAULT_PATH),
"vault_configured": vault_exists(), "vault_configured": vault_exists(),
"local_vault_available": unlocked,
"local_vault_key_path": str(LOCAL_VAULT_KEY_PATH),
"locked": not unlocked, "locked": not unlocked,
"lock_reason": gateway_locked_reason(), "lock_reason": gateway_locked_reason(),
"idle_timeout_seconds": GATEWAY_IDLE_TIMEOUT_SECONDS,
"cwd": str(pathlib.Path.cwd()), "cwd": str(pathlib.Path.cwd()),
"base_url": BASE_URL, "base_url": BASE_URL,
"project_id": DEFAULT_PROJECT_ID, "project_id": DEFAULT_PROJECT_ID,
@@ -1173,22 +1199,48 @@ def ensure_gateway_worker_capability(refresh=True):
def apply_vault_secrets(secrets_payload): def apply_vault_secrets(secrets_payload):
if not isinstance(secrets_payload, dict): if not isinstance(secrets_payload, dict):
raise ValueError("gateway unlock failed") raise ValueError("local gateway vault open failed")
secrets_map = secrets_payload.get("secrets", secrets_payload) secrets_map = secrets_payload.get("secrets", secrets_payload)
if not isinstance(secrets_map, dict): if not isinstance(secrets_map, dict):
raise ValueError("gateway unlock failed") raise ValueError("local gateway vault open failed")
return {key: value for key, value in secrets_map.items() if value is not None} return {key: value for key, value in secrets_map.items() if value is not None}
def gateway_unlock(passphrase): def gateway_open_local(passphrase=None):
global VAULT_SECRETS, VAULT_KEY, GATEWAY_UNLOCKED, AUTH, BASE_URL, TOKEN, DEFAULT_PROJECT_ID, TOKEN_EXPIRES_AT, GATEWAY_PROJECT_BUNDLE_PASSPHRASE global VAULT_SECRETS, VAULT_KEY, GATEWAY_UNLOCKED, AUTH, BASE_URL, TOKEN, DEFAULT_PROJECT_ID, TOKEN_EXPIRES_AT, GATEWAY_PROJECT_BUNDLE_PASSPHRASE
if not vault_exists(): if not vault_exists():
raise RuntimeError("gateway vault is not configured") raise RuntimeError("gateway vault is not configured")
local_key = read_local_vault_key(create=False)
vault_payload = None
vault_key = None
migrated_from_passphrase = False
try:
if local_key:
try:
vault_payload, vault_key = read_vault(local_key, return_key=True)
except Exception:
if not passphrase:
raise
vault_payload, _legacy_key = read_vault(passphrase, return_key=True)
write_vault(local_key, vault_payload)
vault_payload, vault_key = read_vault(local_key, return_key=True)
migrated_from_passphrase = True
elif passphrase:
vault_payload, _legacy_key = read_vault(passphrase, return_key=True)
local_key = read_local_vault_key(create=True)
write_vault(local_key, vault_payload)
vault_payload, vault_key = read_vault(local_key, return_key=True)
migrated_from_passphrase = True
else:
raise RuntimeError("local gateway vault key is unavailable")
except Exception as exc:
raise RuntimeError(
"local gateway vault is unavailable; run gateway setup/configure once with the existing passphrase to migrate local access"
) from exc
try: try:
vault_payload, vault_key = read_vault(passphrase, return_key=True)
secrets_map = apply_vault_secrets(vault_payload) secrets_map = apply_vault_secrets(vault_payload)
except Exception as exc: except Exception as exc:
raise RuntimeError("gateway unlock failed") from exc raise RuntimeError("local gateway vault is unavailable") from exc
with GATEWAY_LOCK: with GATEWAY_LOCK:
public = load_public_auth_file() public = load_public_auth_file()
recovered_auth = auth_file_secret_subset(secrets_map) recovered_auth = auth_file_secret_subset(secrets_map)
@@ -1218,22 +1270,27 @@ def gateway_unlock(passphrase):
TOKEN = AUTH["token"] TOKEN = AUTH["token"]
DEFAULT_PROJECT_ID = AUTH["project_id"] DEFAULT_PROJECT_ID = AUTH["project_id"]
TOKEN_EXPIRES_AT = AUTH["token_expires_at"] TOKEN_EXPIRES_AT = AUTH["token_expires_at"]
if passphrase:
GATEWAY_PROJECT_BUNDLE_PASSPHRASE = str(passphrase) GATEWAY_PROJECT_BUNDLE_PASSPHRASE = str(passphrase)
write_gateway_relay_secret(passphrase) write_gateway_relay_secret(passphrase)
persist_vault_secrets() persist_vault_secrets()
touch_gateway() touch_gateway()
return { return {
"status": "unlocked", "status": "local_available",
"message": "Gateway vault unlocked.", "message": "Gateway local vault is available.",
"project_id": DEFAULT_PROJECT_ID, "project_id": DEFAULT_PROJECT_ID,
"idle_timeout_seconds": GATEWAY_IDLE_TIMEOUT_SECONDS, "migrated_local_key": migrated_from_passphrase,
} }
def gateway_unlock(passphrase):
return gateway_open_local(passphrase)
def require_unlocked(operation="operation"): def require_unlocked(operation="operation"):
reason = gateway_locked_reason() reason = gateway_locked_reason()
if reason: if reason:
raise RuntimeError(f"gateway is locked; unlock with the master passphrase before {operation}") raise RuntimeError(f"local gateway vault is unavailable before {operation}: {reason}")
touch_gateway() touch_gateway()
@@ -1261,9 +1318,9 @@ def normalize_gateway_command(value=None):
def gateway_setup(args): def gateway_setup(args):
passphrase = args.get("passphrase") or read_master_passphrase(create=True) passphrase = args.get("passphrase") or os.environ.get("NAPSEER_GATEWAY_PASSPHRASE") or read_master_passphrase(create=True)
if vault_exists() and not args.get("overwrite"): if vault_exists() and not args.get("overwrite"):
result = gateway_unlock(passphrase) result = gateway_open_local(passphrase)
capability_result = ensure_gateway_worker_capability(refresh=True) capability_result = ensure_gateway_worker_capability(refresh=True)
if args.get("default_command"): if args.get("default_command"):
gateway_tmux_configure({"default_command": args.get("default_command")}) gateway_tmux_configure({"default_command": args.get("default_command")})
@@ -1279,9 +1336,10 @@ def gateway_setup(args):
"gateway_remote_enabled": True, "gateway_remote_enabled": True,
} }
payload = {"version": 1, "created_at": iso_now(), "secrets": secrets_map} payload = {"version": 1, "created_at": iso_now(), "secrets": secrets_map}
write_vault(passphrase, payload) local_key = read_local_vault_key(create=True)
write_vault(local_key, payload)
write_public_auth({**public, "gateway_auth_required": True, "vault_path": str(VAULT_PATH)}) write_public_auth({**public, "gateway_auth_required": True, "vault_path": str(VAULT_PATH)})
result = gateway_unlock(passphrase) result = gateway_open_local(passphrase)
write_gateway_relay_state( write_gateway_relay_state(
remote_enabled=True, remote_enabled=True,
tmux_target={}, tmux_target={},
@@ -1292,11 +1350,11 @@ def gateway_setup(args):
) )
write_gateway_relay_secret(passphrase) write_gateway_relay_secret(passphrase)
capability_result = ensure_gateway_worker_capability(refresh=True) capability_result = ensure_gateway_worker_capability(refresh=True)
return {**gateway_status(), "status": result.get("status", "unlocked"), "message": "Gateway vault configured and unlocked.", "gateway_capability": capability_result} return {**gateway_status(), "status": result.get("status", "local_available"), "message": "Gateway local vault configured.", "gateway_capability": capability_result}
def gateway_rotate_passphrase(args): def gateway_rotate_passphrase(args):
require_unlocked("rotating gateway passphrase") require_unlocked("rotating gateway relay passphrase")
new_passphrase = ( new_passphrase = (
args.get("new_passphrase") args.get("new_passphrase")
or args.get("passphrase") or args.get("passphrase")
@@ -1309,17 +1367,19 @@ def gateway_rotate_passphrase(args):
secrets_map = dict(VAULT_SECRETS) secrets_map = dict(VAULT_SECRETS)
secrets_map.setdefault("gateway_encryption_key", base64.b64encode(secrets.token_bytes(32)).decode("ascii")) secrets_map.setdefault("gateway_encryption_key", base64.b64encode(secrets.token_bytes(32)).decode("ascii"))
secrets_map.setdefault("gateway_remote_enabled", True) secrets_map.setdefault("gateway_remote_enabled", True)
write_vault(new_passphrase, {"version": 1, "updated_at": iso_now(), "secrets": secrets_map}) local_key = read_local_vault_key(create=True)
vault_payload, vault_key = read_vault(new_passphrase, return_key=True) write_vault(local_key, {"version": 1, "updated_at": iso_now(), "secrets": secrets_map})
vault_payload, vault_key = read_vault(local_key, return_key=True)
apply_vault_secrets(vault_payload) apply_vault_secrets(vault_payload)
globals()["VAULT_KEY"] = vault_key globals()["VAULT_KEY"] = vault_key
globals()["VAULT_SECRETS"] = gateway_vault_secret_subset(secrets_map) globals()["VAULT_SECRETS"] = gateway_vault_secret_subset(secrets_map)
globals()["GATEWAY_PROJECT_BUNDLE_PASSPHRASE"] = new_passphrase
write_gateway_relay_secret(new_passphrase) write_gateway_relay_secret(new_passphrase)
touch_gateway() touch_gateway()
return { return {
**gateway_status(), **gateway_status(),
"status": "rotated", "status": "rotated",
"message": "Gateway passphrase rotated. Update NAPSEER_GATEWAY_PASSPHRASE in compose.yaml or the service environment before restarting the gateway.", "message": "Gateway relay passphrase rotated. Update NAPSEER_GATEWAY_PASSPHRASE in compose.yaml or the service environment before restarting the gateway.",
} }
@@ -3087,7 +3147,7 @@ def project_bundle_passphrase():
value = os.environ.get("NAPSEER_MASTER_PASSPHRASE") value = os.environ.get("NAPSEER_MASTER_PASSPHRASE")
if value: if value:
return value return value
raise RuntimeError("gateway is locked; unlock before handling project key bundles") raise RuntimeError("gateway relay passphrase is required before handling project key bundles")
def project_bundle_hkdf_info(project_id, secret_kind, wrapping_epoch, bundle_version, data_key_epoch): def project_bundle_hkdf_info(project_id, secret_kind, wrapping_epoch, bundle_version, data_key_epoch):
@@ -3909,7 +3969,7 @@ def handle_gateway_relay_session(connect_request, listener_sock=None):
request_id=request_id, request_id=request_id,
reason="missing_gateway_relay_secret", reason="missing_gateway_relay_secret",
) )
raise RuntimeError("gateway relay secret is missing; run nap gateway configure or nap gateway unlock once") raise RuntimeError("gateway relay secret is missing; run nap gateway configure once")
spake2_context = connect_request.get("spake2_context") or {} spake2_context = connect_request.get("spake2_context") or {}
relay_socket = str(connect_request.get("relay_socket") or connect_request.get("relay_lane") or spake2_context.get("relay_socket") or spake2_context.get("relay_lane") or "terminal") relay_socket = str(connect_request.get("relay_socket") or connect_request.get("relay_lane") or spake2_context.get("relay_socket") or spake2_context.get("relay_lane") or "terminal")
sock = ws_connect(relay_ws_url(project_id, session_id, connect_request["relay_ticket"], relay_socket)) sock = ws_connect(relay_ws_url(project_id, session_id, connect_request["relay_ticket"], relay_socket))
@@ -6457,7 +6517,7 @@ def gateway_service_preregister(args=None):
"default_command": args.get("default_command") or os.environ.get("NAPSEER_GATEWAY_COMMAND"), "default_command": args.get("default_command") or os.environ.get("NAPSEER_GATEWAY_COMMAND"),
}) })
elif passphrase and not gateway_is_unlocked(): elif passphrase and not gateway_is_unlocked():
gateway_unlock(passphrase) gateway_open_local(passphrase)
key_path = ensure_local_files() key_path = ensure_local_files()
public_key = pathlib.Path(str(key_path) + ".pub").read_text(encoding="utf-8").strip() public_key = pathlib.Path(str(key_path) + ".pub").read_text(encoding="utf-8").strip()
@@ -6569,13 +6629,13 @@ def gateway_service_activate(args=None):
} }
def gateway_service_unlock_from_env(): def gateway_service_open_local_from_env():
if not vault_exists() or gateway_is_unlocked(): if not vault_exists() or gateway_is_unlocked():
return False return False
passphrase = os.environ.get("NAPSEER_GATEWAY_PASSPHRASE") or os.environ.get("NAPSEER_GATEWAY_PASSKEY") passphrase = os.environ.get("NAPSEER_GATEWAY_PASSPHRASE") or os.environ.get("NAPSEER_GATEWAY_PASSKEY")
if not passphrase: if not passphrase:
return False return False
gateway_unlock(passphrase) gateway_open_local(passphrase)
return True return True
@@ -6594,7 +6654,7 @@ def gateway_service_run(args=None):
or os.environ.get("NAPSEER_SERVICE_BOOTSTRAP_TOKEN") or os.environ.get("NAPSEER_SERVICE_BOOTSTRAP_TOKEN")
or os.environ.get("NAPSEER_GATEWAY_BOOTSTRAP_TOKEN") or os.environ.get("NAPSEER_GATEWAY_BOOTSTRAP_TOKEN")
) )
gateway_service_unlock_from_env() gateway_service_open_local_from_env()
if bootstrap_token and not TOKEN: if bootstrap_token and not TOKEN:
try: try:
gateway_service_preregister(args) gateway_service_preregister(args)
@@ -6609,7 +6669,7 @@ def gateway_service_run(args=None):
activated = False activated = False
while not TOKEN and time.time() < deadline: while not TOKEN and time.time() < deadline:
try: try:
gateway_service_unlock_from_env() gateway_service_open_local_from_env()
gateway_service_activate(args) gateway_service_activate(args)
activated = True activated = True
break break
@@ -6621,7 +6681,7 @@ def gateway_service_run(args=None):
if not TOKEN and not activated: if not TOKEN and not activated:
raise RuntimeError("gateway service was not accepted before activation timeout") raise RuntimeError("gateway service was not accepted before activation timeout")
if not gateway_is_unlocked(): if not gateway_is_unlocked():
gateway_service_unlock_from_env() gateway_service_open_local_from_env()
result = start_local_ui({"open_browser": False, "port": int(args.get("port") or os.environ.get("NAPSEER_GATEWAY_PORT", "0"))}) result = start_local_ui({"open_browser": False, "port": int(args.get("port") or os.environ.get("NAPSEER_GATEWAY_PORT", "0"))})
print(json.dumps(result, indent=2)) print(json.dumps(result, indent=2))
try: try:
@@ -6707,7 +6767,7 @@ def bootstrap_project(args):
if encryption == "encrypted": if encryption == "encrypted":
raise RuntimeError( raise RuntimeError(
"CLI direct project encryption enable is disabled for backend-owned HashiCorp project secrets. " "CLI direct project encryption enable is disabled for backend-owned HashiCorp project secrets. "
"Create the project, configure/unlock the gateway vault, then run `nap gateway vault process`." "Create the project, configure the local gateway, then run `nap gateway vault process`."
) )
project, project_status, message = create_project_with_state(args) project, project_status, message = create_project_with_state(args)
encryption_state = "plaintext" encryption_state = "plaintext"
@@ -6745,7 +6805,7 @@ def project_encryption_transition(args=None):
if desired == "encrypted": if desired == "encrypted":
raise RuntimeError( raise RuntimeError(
"CLI direct project encryption enable is disabled for backend-owned HashiCorp project secrets. " "CLI direct project encryption enable is disabled for backend-owned HashiCorp project secrets. "
"Create the project, configure/unlock the gateway vault, then run `nap gateway vault process`." "Create the project, configure the local gateway, then run `nap gateway vault process`."
) )
project_id = resolve_project_id(args) project_id = resolve_project_id(args)
operation = "disable" operation = "disable"
@@ -6908,7 +6968,7 @@ def cli_gateway_configure(args):
default_command = cli_option(args, "--command", default=cli_option(args, "--default-command", default=None)) default_command = cli_option(args, "--command", default=cli_option(args, "--default-command", default=None))
if bootstrap_token: if bootstrap_token:
if passphrase and vault_exists() and not gateway_is_unlocked(): if passphrase and vault_exists() and not gateway_is_unlocked():
gateway_unlock(passphrase) gateway_open_local(passphrase)
if TOKEN: if TOKEN:
if default_command: if default_command:
gateway_tmux_configure({"default_command": default_command}) gateway_tmux_configure({"default_command": default_command})
@@ -6931,9 +6991,11 @@ def cli_gateway_configure(args):
return gateway_bootstrap_token_invalid_result() return gateway_bootstrap_token_invalid_result()
if not vault_exists(): if not vault_exists():
if not passphrase:
raise RuntimeError("gateway relay passphrase is required to create gateway storage")
gateway_setup({"passphrase": passphrase}) gateway_setup({"passphrase": passphrase})
if not gateway_is_unlocked(): if not gateway_is_unlocked():
gateway_unlock(passphrase or getpass.getpass("Gateway passphrase: ")) gateway_open_local(passphrase)
target = parse_tmux_target(cli_option(args, "--target", default="")) target = parse_tmux_target(cli_option(args, "--target", default=""))
command = default_command or "" command = default_command or ""
@@ -6981,7 +7043,7 @@ def mcp_gateway_configure(args):
default_command = args.get("default_command") or args.get("command") default_command = args.get("default_command") or args.get("command")
if bootstrap_token: if bootstrap_token:
if passphrase and vault_exists() and not gateway_is_unlocked(): if passphrase and vault_exists() and not gateway_is_unlocked():
gateway_unlock(passphrase) gateway_open_local(passphrase)
if TOKEN: if TOKEN:
if default_command: if default_command:
gateway_tmux_configure({"default_command": default_command}) gateway_tmux_configure({"default_command": default_command})
@@ -7010,7 +7072,7 @@ def mcp_gateway_configure(args):
raise RuntimeError("gateway passphrase is required to create the gateway vault") raise RuntimeError("gateway passphrase is required to create the gateway vault")
gateway_setup({"passphrase": passphrase, "default_command": default_command}) gateway_setup({"passphrase": passphrase, "default_command": default_command})
elif passphrase and not gateway_is_unlocked(): elif passphrase and not gateway_is_unlocked():
gateway_unlock(passphrase) gateway_open_local(passphrase)
target = parse_tmux_target(args.get("target", "")) target = parse_tmux_target(args.get("target", ""))
payload = { payload = {
@@ -7028,7 +7090,7 @@ def cli_gateway_require_unlocked(args, operation="gateway operation"):
if not vault_exists(): if not vault_exists():
raise RuntimeError("gateway vault is not configured; run nap gateway configure first") raise RuntimeError("gateway vault is not configured; run nap gateway configure first")
if not gateway_is_unlocked(): if not gateway_is_unlocked():
gateway_unlock(cli_option(args, "--passphrase", default=None) or getpass.getpass("Gateway passphrase: ")) gateway_open_local(cli_option(args, "--passphrase", default=None))
require_unlocked(operation) require_unlocked(operation)
@@ -9458,9 +9520,10 @@ def start_local_ui(args):
def handle_local_api_post(self, route, payload): def handle_local_api_post(self, route, payload):
project_id = current_project_id() project_id = current_project_id()
if route == "/local-api/gateway/unlock": if route == "/local-api/gateway/unlock":
return gateway_unlock(payload.get("passphrase", "")) return gateway_open_local(payload.get("passphrase", ""))
if route == "/local-api/gateway/lock": if route == "/local-api/gateway/lock":
return gateway_lock() clear_gateway_runtime_caches()
return gateway_status()
if route == "/local-api/gateway/setup": if route == "/local-api/gateway/setup":
return gateway_setup(payload) return gateway_setup(payload)
if route == "/local-api/gateway/configure": if route == "/local-api/gateway/configure":
@@ -9574,7 +9637,7 @@ def start_local_ui(args):
<label>Project slug<input name="slug" required value="{html.escape(default_project_slug())}"></label> <label>Project slug<input name="slug" required value="{html.escape(default_project_slug())}"></label>
<label>Description<textarea name="description"></textarea></label> <label>Description<textarea name="description"></textarea></label>
<input type="hidden" name="encryption" value="plaintext"> <input type="hidden" name="encryption" value="plaintext">
<p>Project encryption is configured after project creation from an unlocked gateway with <code>nap gateway vault process</code>.</p> <p>Project encryption is configured after project creation from the local gateway with <code>nap gateway vault process</code>.</p>
<button type="submit">Create project</button></form></section></main></body></html>""") <button type="submit">Create project</button></form></section></main></body></html>""")
return return
search = query.get("q", [""])[0] search = query.get("q", [""])[0]
@@ -9690,9 +9753,10 @@ def start_local_ui(args):
if route == "/gateway/setup": if route == "/gateway/setup":
result = gateway_setup(payload) result = gateway_setup(payload)
elif route == "/gateway/unlock": elif route == "/gateway/unlock":
result = gateway_unlock(payload.get("passphrase", "")) result = gateway_open_local(payload.get("passphrase", ""))
elif route == "/gateway/lock": elif route == "/gateway/lock":
result = gateway_lock() clear_gateway_runtime_caches()
result = gateway_status()
elif route == "/gateway/tmux/configure": elif route == "/gateway/tmux/configure":
result = gateway_tmux_configure(payload) result = gateway_tmux_configure(payload)
elif route == "/gateway/terminal/restart": elif route == "/gateway/terminal/restart":
@@ -9918,17 +9982,17 @@ def raw_tools():
}, },
{ {
"name": "nap_gateway_status", "name": "nap_gateway_status",
"description": "Show whether the encrypted local gateway vault is configured and unlocked. Does not reveal secrets.", "description": "Show local gateway, relay, and project status. Does not reveal secrets.",
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}, "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
}, },
{ {
"name": "nap_gateway_setup", "name": "nap_gateway_setup",
"description": "Create or migrate the encrypted local gateway vault using a master passphrase and default managed agent command.", "description": "Create or migrate local gateway storage and configure the remote relay passphrase.",
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"required": ["passphrase"], "required": ["passphrase"],
"properties": { "properties": {
"passphrase": {"type": "string", "description": "Master passphrase used only locally to encrypt the gateway vault."}, "passphrase": {"type": "string", "description": "Remote relay passphrase. Local access is protected by the machine/user session, not an interactive unlock gate."},
"default_command": {"type": "string", "description": "Command Napseer should launch in its managed tmux window, for example bash -l."}, "default_command": {"type": "string", "description": "Command Napseer should launch in its managed tmux window, for example bash -l."},
"overwrite": {"type": "boolean"}, "overwrite": {"type": "boolean"},
}, },
@@ -9966,7 +10030,7 @@ def raw_tools():
}, },
{ {
"name": "nap_gateway_vault_process", "name": "nap_gateway_vault_process",
"description": "Complete pending project vault setup requests assigned to this unlocked gateway by uploading opaque client-wrapped key bundle records for backend-owned HashiCorp storage.", "description": "Complete pending project vault setup requests assigned to this local gateway by uploading opaque client-wrapped key bundle records for backend-owned HashiCorp storage.",
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -9978,7 +10042,7 @@ def raw_tools():
}, },
{ {
"name": "nap_gateway_vault_secret_rotate", "name": "nap_gateway_vault_secret_rotate",
"description": "Rotate one project secret version from the unlocked gateway by uploading an opaque client-wrapped key bundle record for backend-owned HashiCorp storage.", "description": "Rotate one project secret version from this local gateway by uploading an opaque client-wrapped key bundle record for backend-owned HashiCorp storage.",
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"required": ["secret_kind"], "required": ["secret_kind"],
@@ -9989,21 +10053,6 @@ def raw_tools():
"additionalProperties": False, "additionalProperties": False,
}, },
}, },
{
"name": "nap_gateway_unlock",
"description": "Unlock the encrypted local gateway vault with the master passphrase.",
"inputSchema": {
"type": "object",
"required": ["passphrase"],
"properties": {"passphrase": {"type": "string"}},
"additionalProperties": False,
},
},
{
"name": "nap_gateway_lock",
"description": "Lock the local gateway and clear decrypted secrets from memory.",
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
},
{ {
"name": "nap_gateway_restart", "name": "nap_gateway_restart",
"description": "Kill and relaunch the managed gateway tmux window using the configured default command.", "description": "Kill and relaunch the managed gateway tmux window using the configured default command.",
@@ -10918,10 +10967,8 @@ TOOL_CATEGORIES = {
"nap_gateway_vault_ls", "nap_gateway_vault_ls",
"nap_gateway_vault_process", "nap_gateway_vault_process",
"nap_gateway_vault_secret_rotate", "nap_gateway_vault_secret_rotate",
"nap_gateway_unlock",
"nap_gateway_restart", "nap_gateway_restart",
"nap_gateway_kill", "nap_gateway_kill",
"nap_gateway_lock",
"nap_ui_open", "nap_ui_open",
], ],
"maintenance": [ "maintenance": [
@@ -10993,13 +11040,11 @@ LOCAL_FILE_TOOLS = {
"nap_gateway_configure", "nap_gateway_configure",
"nap_gateway_vault_process", "nap_gateway_vault_process",
"nap_gateway_vault_secret_rotate", "nap_gateway_vault_secret_rotate",
"nap_gateway_unlock",
"nap_gateway_lock",
} }
PROCESS_CONTROL_TOOLS = {"nap_ui_open"} PROCESS_CONTROL_TOOLS = {"nap_ui_open"}
TERMINAL_CONTROL_TOOLS = {"nap_gateway_restart", "nap_gateway_kill"} TERMINAL_CONTROL_TOOLS = {"nap_gateway_restart", "nap_gateway_kill"}
SECRET_ACCEPTING_TOOLS = {"nap_gateway_setup", "nap_gateway_unlock"} SECRET_ACCEPTING_TOOLS = {"nap_gateway_setup", "nap_gateway_configure"}
AUTO_LOCK_TOOLS = { AUTO_LOCK_TOOLS = {
"nap_create_node", "nap_create_node",
"nap_tee", "nap_tee",
@@ -11045,7 +11090,7 @@ def tool_auth_mode(name):
if name in {"nap_apropos", "nap_man", "nap_contract", "nap_update_status"}: if name in {"nap_apropos", "nap_man", "nap_contract", "nap_update_status"}:
return "public" return "public"
if category == "gateway": if category == "gateway":
return "gateway-vault" if name in SECRET_ACCEPTING_TOOLS or name.startswith("nap_gateway_") else "local-only" return "local-gateway"
if name in {"nap_grep_local", "nap_index_status", "nap_index_clear", "nap_reindex_project", "nap_ui_open", "nap_update_self"}: if name in {"nap_grep_local", "nap_index_status", "nap_index_clear", "nap_reindex_project", "nap_ui_open", "nap_update_self"}:
return "local-only" return "local-only"
if category in {"explore", "update", "coordination", "agent", "project", "schedules", "maintenance"}: if category in {"explore", "update", "coordination", "agent", "project", "schedules", "maintenance"}:
@@ -11268,14 +11313,10 @@ def call_tool_impl(name, args):
return gateway_process_vault_setup_requests(args) return gateway_process_vault_setup_requests(args)
if name == "nap_gateway_vault_secret_rotate": if name == "nap_gateway_vault_secret_rotate":
return gateway_rotate_project_vault_secret(args) return gateway_rotate_project_vault_secret(args)
if name == "nap_gateway_unlock":
return gateway_unlock(args.get("passphrase", ""))
if name == "nap_gateway_restart": if name == "nap_gateway_restart":
return gateway_terminal_restart(args) return gateway_terminal_restart(args)
if name == "nap_gateway_kill": if name == "nap_gateway_kill":
return gateway_terminal_kill(args) return gateway_terminal_kill(args)
if name == "nap_gateway_lock":
return gateway_lock()
if name == "nap_login": if name == "nap_login":
return renew_auth() return renew_auth()
if name == "nap_update_status": if name == "nap_update_status":
@@ -11762,13 +11803,7 @@ def cli_main(argv):
"new_passphrase": cli_option(args, "--new-passphrase", "--passphrase", default=None), "new_passphrase": cli_option(args, "--new-passphrase", "--passphrase", default=None),
}), indent=2)) }), indent=2))
return return
if subcommand == "unlock": 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(json.dumps(gateway_unlock(cli_option(args, "--passphrase", default=None) or getpass.getpass("Gateway passphrase: ")), indent=2))
return
if subcommand == "lock":
print(json.dumps(gateway_lock(), 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 [--passphrase TEXT]|kill [--passphrase TEXT]|setup|rotate-passphrase --new-passphrase TEXT|unlock|lock]")
return return
if command == "feedback": if command == "feedback":
subcommand = argv[2] if len(argv) > 2 else "list" subcommand = argv[2] if len(argv) > 2 else "list"
+7 -7
View File
@@ -591,18 +591,18 @@ def handle_gateway(args):
subcommand = args[0] if args else "ls" subcommand = args[0] if args else "ls"
rest = args[1:] if args else [] rest = args[1:] if args else []
if subcommand in {"help", "-h", "--help"}: if subcommand in {"help", "-h", "--help"}:
print("Usage: nap gateway [start|stop|status|logs|configure|setup|unlock|lock|restart|kill|vault|terminal|schedule|service]") print("Usage: nap gateway [start|stop|status|logs|configure|setup|restart|kill|vault|terminal|schedule|service]")
print(" nap gateway start [--port PORT] Start the local gateway service.") print(" nap gateway start [--port PORT] Start the local gateway service.")
print(" nap gateway status Show the managed gateway process.") print(" nap gateway status Show the managed gateway process.")
print(" nap gateway configure [--command CMD] Configure the local gateway command.") print(" nap gateway configure [--command CMD] Configure the local gateway command.")
print(" nap gateway setup Create or unlock the encrypted gateway vault.") 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 Show gateway vault status and pending setup requests.")
print(" nap gateway vault process Complete 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 chat|tabs|gateway|memory")
return 0 return 0
if subcommand == "vault" and rest and rest[0] in {"help", "-h", "--help"}: if subcommand == "vault" and rest and rest[0] in {"help", "-h", "--help"}:
print("Usage: nap gateway vault [status|list|process|rotate-secret] [--kind chat|tabs|gateway|memory] [--all] [--project-id ID]") print("Usage: nap gateway vault [status|list|process|rotate-secret] [--kind chat|tabs|gateway|memory] [--all] [--project-id ID]")
print(" status Show configured/locked state and pending setup requests.") print(" status Show local gateway state and pending setup requests.")
print(" process Upload opaque client-wrapped key bundle records for backend-owned HashiCorp storage.") print(" process Upload opaque client-wrapped key bundle records for backend-owned HashiCorp storage.")
print(" rotate-secret Rotate one encrypted project secret.") print(" rotate-secret Rotate one encrypted project secret.")
return 0 return 0
@@ -616,9 +616,9 @@ def handle_gateway(args):
return print_json(service_logs("gateway", rest)) or 0 return print_json(service_logs("gateway", rest)) or 0
if subcommand == "configure": if subcommand == "configure":
return run_wrapper("gateway", ["configure", *rest]) return run_wrapper("gateway", ["configure", *rest])
if subcommand in {"setup", "unlock", "lock", "restart", "kill", "vault", "terminal", "schedule", "service"}: if subcommand in {"setup", "restart", "kill", "vault", "terminal", "schedule", "service"}:
return run_wrapper("gateway", [subcommand, *rest]) return run_wrapper("gateway", [subcommand, *rest])
raise RuntimeError("unknown gateway command; use nap gateway start|stop|configure|setup|unlock|lock|restart|kill|logs|status|vault|terminal|schedule|service") raise RuntimeError("unknown gateway command; use nap gateway start|stop|configure|setup|restart|kill|logs|status|vault|terminal|schedule|service")
def print_json(value): def print_json(value):
@@ -675,8 +675,8 @@ def main(argv):
print(" Example: nap auth login-local") print(" Example: nap auth login-local")
print(" project Project commands: create, status, encryption.") print(" project Project commands: create, status, encryption.")
print(" Examples: nap project create, nap project status, nap project encryption status") print(" Examples: nap project create, nap project status, nap project encryption status")
print(" Encrypted project setup is completed by an unlocked gateway: nap gateway vault process") print(" Encrypted project setup is completed by the local gateway: nap gateway vault process")
print(" gateway Gateway commands: start, stop, configure, setup, unlock, lock, restart, kill, logs, status, vault.") 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(" 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 rotate-secret --kind chat")
print(" agent Agent commands: list, workspaces, create, show, edit.") print(" agent Agent commands: list, workspaces, create, show, edit.")
+17 -17
View File
@@ -24,7 +24,7 @@ def assert_equal(actual, expected, message):
def run(): def run():
mod = load_module() mod = load_module()
calls = {"preregister": [], "setup": [], "unlock": [], "tmux": []} calls = {"preregister": [], "setup": [], "open": [], "tmux": []}
mod.AUTH = {} mod.AUTH = {}
mod.TOKEN = None mod.TOKEN = None
@@ -32,7 +32,7 @@ def run():
mod.vault_exists = lambda: False mod.vault_exists = lambda: False
mod.gateway_is_unlocked = lambda: False mod.gateway_is_unlocked = lambda: False
mod.gateway_setup = lambda payload: calls["setup"].append(payload) or {"status": "configured"} mod.gateway_setup = lambda payload: calls["setup"].append(payload) or {"status": "configured"}
mod.gateway_unlock = lambda passphrase: calls["unlock"].append(passphrase) or {"status": "unlocked"} mod.gateway_open_local = lambda passphrase=None: calls["open"].append(passphrase) or {"status": "local_available"}
mod.gateway_tmux_configure = lambda payload: calls["tmux"].append(payload) or {"status": "configured"} mod.gateway_tmux_configure = lambda payload: calls["tmux"].append(payload) or {"status": "configured"}
mod.gateway_status = lambda: {"status": "ok"} mod.gateway_status = lambda: {"status": "ok"}
mod.gateway_log = lambda *args, **kwargs: None mod.gateway_log = lambda *args, **kwargs: None
@@ -83,19 +83,19 @@ def run():
assert_equal(invalid_cli["status"], "bootstrap_token_invalid", "cli invalid token fallback") assert_equal(invalid_cli["status"], "bootstrap_token_invalid", "cli invalid token fallback")
calls["preregister"].clear() calls["preregister"].clear()
calls["unlock"].clear() calls["open"].clear()
calls["tmux"].clear() calls["tmux"].clear()
mod.gateway_service_preregister = preregister mod.gateway_service_preregister = preregister
mod.TOKEN = None mod.TOKEN = None
mod.vault_exists = lambda: True mod.vault_exists = lambda: True
mod.gateway_is_unlocked = lambda: False mod.gateway_is_unlocked = lambda: False
def unlock_existing(passphrase): def open_existing(passphrase=None):
calls["unlock"].append(passphrase) calls["open"].append(passphrase)
mod.TOKEN = "np_existing_worker" mod.TOKEN = "np_existing_worker"
return {"status": "unlocked"} return {"status": "local_available"}
mod.gateway_unlock = unlock_existing mod.gateway_open_local = open_existing
preserved_mcp = mod.mcp_gateway_configure({ preserved_mcp = mod.mcp_gateway_configure({
"bootstrap_token": "npb_existing", "bootstrap_token": "npb_existing",
"passphrase": "pw_existing", "passphrase": "pw_existing",
@@ -109,31 +109,31 @@ def run():
assert_equal(preserved_mcp["status"], "configured", "mcp preserved gateway should skip preregister") assert_equal(preserved_mcp["status"], "configured", "mcp preserved gateway should skip preregister")
assert_equal(preserved_cli["status"], "configured", "cli preserved gateway should skip preregister") assert_equal(preserved_cli["status"], "configured", "cli preserved gateway should skip preregister")
assert_equal(calls["preregister"], [], "preserved gateway preregister calls") assert_equal(calls["preregister"], [], "preserved gateway preregister calls")
assert_equal(calls["unlock"], ["pw_existing", "pw_existing"], "preserved gateway unlocks before preregister") assert_equal(calls["open"], ["pw_existing", "pw_existing"], "preserved gateway opens local storage before preregister")
assert_equal(len(calls["tmux"]), 2, "preserved gateway command updates") assert_equal(len(calls["tmux"]), 2, "preserved gateway command updates")
calls["preregister"].clear() calls["preregister"].clear()
calls["unlock"].clear() calls["open"].clear()
mod.TOKEN = None mod.TOKEN = None
mod.vault_exists = lambda: True mod.vault_exists = lambda: True
service_unlocked = {"value": False} service_available = {"value": False}
mod.gateway_is_unlocked = lambda: service_unlocked["value"] mod.gateway_is_unlocked = lambda: service_available["value"]
os.environ["NAPSEER_GATEWAY_PASSPHRASE"] = "pw_service" os.environ["NAPSEER_GATEWAY_PASSPHRASE"] = "pw_service"
os.environ["NAPSEER_GATEWAY_BOOTSTRAP_TOKEN"] = "npb_service" os.environ["NAPSEER_GATEWAY_BOOTSTRAP_TOKEN"] = "npb_service"
mod.start_local_ui = lambda payload: {"status": "started", "payload": payload} mod.start_local_ui = lambda payload: {"status": "started", "payload": payload}
def service_unlock(passphrase): def service_open(passphrase=None):
calls["unlock"].append(passphrase) calls["open"].append(passphrase)
mod.TOKEN = "np_existing_worker" mod.TOKEN = "np_existing_worker"
service_unlocked["value"] = True service_available["value"] = True
return {"status": "unlocked"} return {"status": "local_available"}
mod.gateway_unlock = service_unlock mod.gateway_open_local = service_open
mod.threading.Event = lambda: type("StopEvent", (), {"wait": lambda self: (_ for _ in ()).throw(KeyboardInterrupt())})() mod.threading.Event = lambda: type("StopEvent", (), {"wait": lambda self: (_ for _ in ()).throw(KeyboardInterrupt())})()
service_result = mod.gateway_service_run({"timeout_seconds": 0}) service_result = mod.gateway_service_run({"timeout_seconds": 0})
assert_equal(service_result["status"], "started", "service run with preserved token") assert_equal(service_result["status"], "started", "service run with preserved token")
assert_equal(calls["preregister"], [], "service run preserved gateway preregister calls") assert_equal(calls["preregister"], [], "service run preserved gateway preregister calls")
assert_equal(calls["unlock"], ["pw_service"], "service run unlocks before preregister") assert_equal(calls["open"], ["pw_service"], "service run opens local storage before preregister")
print("ok: gateway configure CLI/MCP parity smoke passed") print("ok: gateway configure CLI/MCP parity smoke passed")
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Smoke-test auth refresh while the gateway vault is locked.""" """Smoke-test auth refresh with local gateway storage available."""
import importlib.util import importlib.util
import pathlib import pathlib
@@ -20,8 +20,9 @@ def run():
mod = load_module() mod = load_module()
writes = [] writes = []
vault_writes = []
mod.VAULT_SECRETS = {} mod.VAULT_SECRETS = {}
mod.VAULT_KEY = None mod.VAULT_KEY = b"k" * 32
mod.AUTH = { mod.AUTH = {
"base_url": "https://api.example.test", "base_url": "https://api.example.test",
"project_id": "project_1", "project_id": "project_1",
@@ -35,7 +36,7 @@ def run():
mod.TOKEN = "expired" mod.TOKEN = "expired"
mod.TOKEN_EXPIRES_AT = "old" mod.TOKEN_EXPIRES_AT = "old"
mod.vault_exists = lambda: True mod.vault_exists = lambda: True
mod.gateway_is_unlocked = lambda: False mod.gateway_is_unlocked = lambda: True
mod.load_public_auth_file = lambda: { mod.load_public_auth_file = lambda: {
"base_url": "https://api.example.test", "base_url": "https://api.example.test",
"project_id": "project_1", "project_id": "project_1",
@@ -46,10 +47,8 @@ def run():
} }
mod.write_public_auth = lambda payload: writes.append(dict(payload)) mod.write_public_auth = lambda payload: writes.append(dict(payload))
def locked(operation="operation"): mod.require_unlocked = lambda operation="operation": None
raise RuntimeError(f"gateway is locked; unlock with the master passphrase before {operation}") mod.write_vault_with_key = lambda key, payload: vault_writes.append((key, payload))
mod.require_unlocked = locked
mod.save_auth({"token": "fresh", "token_expires_at": "new", "worker_id": "worker_1"}) mod.save_auth({"token": "fresh", "token_expires_at": "new", "worker_id": "worker_1"})
assert writes[-1]["token"] == "fresh" assert writes[-1]["token"] == "fresh"
@@ -71,14 +70,11 @@ def run():
assert mod.TOKEN == "newer" assert mod.TOKEN == "newer"
assert mod.TOKEN_EXPIRES_AT == "later" assert mod.TOKEN_EXPIRES_AT == "later"
try:
mod.save_auth({"gateway_default_command": "bash -l"}) mod.save_auth({"gateway_default_command": "bash -l"})
except RuntimeError as exc: assert vault_writes[-1][0] == b"k" * 32
assert "updating gateway vault" in str(exc) assert vault_writes[-1][1]["secrets"]["gateway_default_command"] == "bash -l"
else:
raise AssertionError("gateway vault updates must still require unlock")
print("ok: locked gateway auth refresh smoke passed") print("ok: local gateway auth refresh smoke passed")
if __name__ == "__main__": if __name__ == "__main__":
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Smoke-test local gateway storage uses a machine-local key, not interactive unlock."""
import importlib.util
import pathlib
import sys
import tempfile
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_local_vault_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()
with tempfile.TemporaryDirectory() as tmp:
state = pathlib.Path(tmp)
mod.AUTH_DIR = state
mod.AUTH_PATH = state / "auth.json"
mod.VAULT_PATH = state / "vault.json"
mod.LOCAL_VAULT_KEY_PATH = state / "vault.local-key"
mod.GATEWAY_RELAY_SECRET_PATH = state / "gateway-relay-secret.json"
mod.GATEWAY_RELAY_STATE_PATH = state / "gateway-relay-state.json"
mod.CONTAINER_IDENTITY_PATH = state / "container-identity.json"
mod.TELEMETRY_PATH = state / "telemetry.json"
auth = {
"base_url": "https://api.example.test",
"project_id": "project-1",
"token": "token",
"token_expires_at": "later",
"ssh_key_path": str(state / "id_ed25519"),
}
mod.AUTH = dict(auth)
mod.BASE_URL = auth["base_url"]
mod.DEFAULT_PROJECT_ID = auth["project_id"]
mod.TOKEN = auth["token"]
mod.TOKEN_EXPIRES_AT = auth["token_expires_at"]
mod.load_auth = lambda public_override=None, secret_override=None: {
**auth,
**(public_override or {}),
**(secret_override or {}),
}
mod.ensure_gateway_worker_capability = lambda refresh=True: {"status": "updated"}
result = mod.gateway_setup({"passphrase": "relay-passphrase", "default_command": "bash -l"})
assert result["status"] == "local_available"
assert mod.LOCAL_VAULT_KEY_PATH.exists()
assert mod.GATEWAY_RELAY_SECRET_PATH.exists()
assert mod.read_vault(mod.LOCAL_VAULT_KEY_PATH.read_text(encoding="utf-8").strip())["secrets"]["gateway_default_command"] == "bash -l"
mod.VAULT_SECRETS = {}
mod.VAULT_KEY = None
mod.GATEWAY_UNLOCKED = False
opened = mod.gateway_open_local()
assert opened["status"] == "local_available"
assert mod.VAULT_SECRETS["gateway_default_command"] == "bash -l"
print("ok: local gateway vault key smoke passed")
if __name__ == "__main__":
run()
+1 -2
View File
@@ -321,8 +321,7 @@ def run():
mod.vault_exists = lambda: True mod.vault_exists = lambda: True
cache_secret(mod, version=4, active=False) cache_secret(mod, version=4, active=False)
assert mod.MEMORY_SECRET_CACHE assert mod.MEMORY_SECRET_CACHE
locked = mod.gateway_lock() mod.clear_gateway_runtime_caches()
assert locked["status"] == "locked"
assert mod.MEMORY_SECRET_CACHE == {} assert mod.MEMORY_SECRET_CACHE == {}
assert mod.ACTIVE_MEMORY_SECRET_VERSIONS == {} assert mod.ACTIVE_MEMORY_SECRET_VERSIONS == {}