feat: configure agents with durable user sessions and scoped access
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user