feat: configure agents with durable user sessions and scoped access

This commit is contained in:
HumbleTurtle
2026-09-10 03:29:48 -03:00
parent e8c6a5109e
commit 6c29a318e8
15 changed files with 1306 additions and 410 deletions
+43 -15
View File
@@ -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",
+240
View File
@@ -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