Persist gateway secrets and worktree state in user data
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
"""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 subprocess
|
||||
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"):
|
||||
path = Path(os.environ["NAPSEER_USER_DATA_DIR"]).expanduser()
|
||||
if not path.is_absolute():
|
||||
raise CredentialError("NAPSEER_USER_DATA_DIR must be an absolute path outside the checkout.")
|
||||
return path.resolve()
|
||||
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"
|
||||
xdg = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
|
||||
return (xdg if xdg.is_absolute() else Path.home() / ".local" / "share") / "napseer"
|
||||
|
||||
|
||||
class WorkspacePaths:
|
||||
"""Pure path resolution: Git identity survives worktree moves, not copies.
|
||||
|
||||
The public locator stays in Git. Repository credentials/signing keys are
|
||||
shared by related worktrees; daemon/vault/cache state belongs to one worktree.
|
||||
No credentials are read and no directory is created during path discovery.
|
||||
"""
|
||||
def __init__(self, root=None):
|
||||
start = Path(root or os.environ.get("NAPSEER_PROJECT_ROOT") or Path.cwd()).expanduser().resolve()
|
||||
self.root = start
|
||||
git_dir = common = None
|
||||
environment = {k:v for k,v in os.environ.items() if k not in
|
||||
{"GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE"}}
|
||||
try:
|
||||
result = subprocess.run(["git", "-C", str(start), "rev-parse", "--path-format=absolute",
|
||||
"--show-toplevel", "--git-dir", "--git-common-dir"],
|
||||
capture_output=True, text=True, timeout=3, env=environment)
|
||||
if result.returncode == 0:
|
||||
top, git_dir, common = result.stdout.strip().splitlines()
|
||||
self.root = Path(top).resolve()
|
||||
except (OSError, ValueError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
# A nested project's explicit locator is a separate namespace within Git.
|
||||
for candidate in (start, *start.parents):
|
||||
if (candidate / ".napseer/project.json").is_file():
|
||||
self.root = candidate
|
||||
break
|
||||
if git_dir and candidate == self.root:
|
||||
break
|
||||
suffix = str(self.root.relative_to(Path(top).resolve())) if git_dir else ""
|
||||
repo_identity = str(Path(common).resolve()) + ":" + suffix if common else str(self.root)
|
||||
worktree_identity = str(Path(git_dir).resolve()) + ":" + suffix if git_dir else str(self.root)
|
||||
self.repository_id = hashlib.sha256(repo_identity.encode()).hexdigest()
|
||||
self.workspace_id = hashlib.sha256(worktree_identity.encode()).hexdigest()
|
||||
self.data = user_data_dir().expanduser().resolve()
|
||||
if self.data.is_relative_to(self.root) and self.root != Path.home().resolve():
|
||||
raise CredentialError("Napseer user data must be outside the project checkout.")
|
||||
self.legacy = self.root / ".napseer"
|
||||
self.locator = self.legacy / "project.json"
|
||||
self.repository = self.data / "repositories" / self.repository_id
|
||||
self.state = self.data / "workspaces" / self.workspace_id
|
||||
self.signing_key = self.repository / "id_ed25519"
|
||||
self.project_credentials = self.data / "credentials/projects" / (self.repository_id + ".json")
|
||||
old_id = hashlib.sha256(str(self.root).encode()).hexdigest()
|
||||
self.old_credentials = self.data / "credentials/projects" / (old_id + ".json")
|
||||
|
||||
def migration_sources(self):
|
||||
if self.legacy.is_symlink():
|
||||
raise CredentialError("Legacy Napseer state must not be a symbolic link.")
|
||||
sources = []
|
||||
if self.legacy.exists():
|
||||
sources = [p for p in self.legacy.iterdir() if p.name not in {"project.json", ".gitignore"}]
|
||||
if self.old_credentials != self.project_credentials and self.old_credentials.exists():
|
||||
sources.append(self.old_credentials)
|
||||
return sources
|
||||
|
||||
def require_migrated(self):
|
||||
if self.migration_sources():
|
||||
raise CredentialError("Repository-local Napseer state needs migration. Stop older MCP clients and local gateways, then run `nap auth migrate`.")
|
||||
|
||||
def migrate(self, *, gateway=False):
|
||||
"""Copy/verify/publish before removing originals; never overwrite conflicts.
|
||||
|
||||
Older processes must be stopped before this explicit operation. Existing
|
||||
daemon PIDs are checked. A partial copy can be retried safely; all source
|
||||
bytes remain until every destination has been verified.
|
||||
"""
|
||||
with credential_lock(self.data / "migration"):
|
||||
sources = self.migration_sources()
|
||||
if not sources:
|
||||
return {"status":"already_migrated", "state_dir":str(self.state)}
|
||||
for name in ("gateway.json", "ui.json"):
|
||||
state = read_json(self.legacy / name)
|
||||
pid = state.get("pid")
|
||||
if isinstance(pid, int) and pid > 0:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
continue
|
||||
except OSError:
|
||||
pass
|
||||
raise CredentialError("Stop the running local gateway/UI before `nap auth migrate`.")
|
||||
entries = []
|
||||
for source in sources:
|
||||
if source.is_symlink() or not source.is_file():
|
||||
raise CredentialError("Migration requires regular files; original state was preserved.")
|
||||
destination = self.state / source.name
|
||||
if source.name in {"id_ed25519", "id_ed25519.pub"}:
|
||||
destination = self.repository / source.name
|
||||
if source.name == "auth.json" or source == self.old_credentials:
|
||||
destination = (self.state / "auth.json") if gateway and source.name == "auth.json" else self.project_credentials
|
||||
original = file_fingerprint(source)
|
||||
copied = None
|
||||
if source.name in {"auth.json", "gateway-auth.json", "state.json"} or source == self.old_credentials:
|
||||
data = read_json(source)
|
||||
for field, old, new in (
|
||||
("ssh_key_path", self.legacy / "id_ed25519", self.signing_key),
|
||||
("vault_path", self.legacy / "vault.json", self.state / "vault.json"),
|
||||
):
|
||||
value = data.get(field)
|
||||
if isinstance(value, str):
|
||||
candidate = Path(value).expanduser()
|
||||
candidate = candidate if candidate.is_absolute() else self.root / candidate
|
||||
if candidate.resolve() == old.resolve(): data[field] = str(new)
|
||||
copied = (json.dumps(data, indent=2) + "\n").encode()
|
||||
if destination.is_symlink():
|
||||
raise CredentialError("Migration destination must not be a symbolic link.")
|
||||
expected = (len(copied), hashlib.sha256(copied).hexdigest()) if copied is not None else original
|
||||
if destination.exists() and file_fingerprint(destination) != expected:
|
||||
raise CredentialError("Migration conflicts with existing private state; nothing was overwritten. Reconcile the two states before retrying.")
|
||||
if any(dst == destination and digest != expected for _,dst,_,_,digest in entries):
|
||||
raise CredentialError("Legacy credentials conflict; original state was preserved.")
|
||||
entries.append((source, destination, original, copied, expected))
|
||||
# Publish credentials last, after their keys and other state exist.
|
||||
entries.sort(key=lambda e: e[1] in {self.project_credentials, self.state / "auth.json"})
|
||||
for source, destination, original, copied, expected in entries:
|
||||
private_directory(destination.parent, self.data)
|
||||
if not destination.exists():
|
||||
fd, temporary = tempfile.mkstemp(prefix=".migrate-", dir=destination.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as stream:
|
||||
os.fchmod(stream.fileno(), 0o600)
|
||||
if copied is not None:
|
||||
stream.write(copied)
|
||||
else:
|
||||
with source.open("rb") as original_stream:
|
||||
while chunk := original_stream.read(1024 * 1024):
|
||||
stream.write(chunk)
|
||||
stream.flush(); os.fsync(stream.fileno())
|
||||
# Publish without replacing another login or migration's file.
|
||||
try:
|
||||
os.link(temporary, destination)
|
||||
except FileExistsError:
|
||||
if file_fingerprint(destination) != expected:
|
||||
raise CredentialError("Migration destination changed; original state was preserved.") from None
|
||||
finally:
|
||||
if os.path.exists(temporary): os.unlink(temporary)
|
||||
if file_fingerprint(destination) != expected or file_fingerprint(source) != original:
|
||||
raise CredentialError("State changed during migration; original files were preserved. Stop older clients and retry.")
|
||||
for source, destination, original, copied, expected in entries:
|
||||
if file_fingerprint(source) != original or file_fingerprint(destination) != expected:
|
||||
raise CredentialError("Migration verification failed; remaining originals were preserved.")
|
||||
# Make published directory entries durable before removing originals.
|
||||
# Windows does not support opening directories for fsync this way.
|
||||
if os.name != "nt":
|
||||
parents = set()
|
||||
for _, destination, _, _, _ in entries:
|
||||
for parent in (destination.parent, *destination.parent.parents):
|
||||
parents.add(parent)
|
||||
if parent == self.data: break
|
||||
for parent in sorted(parents, key=lambda p: len(p.parts), reverse=True):
|
||||
fd = os.open(parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try: os.fsync(fd)
|
||||
finally: os.close(fd)
|
||||
for source, _, _, _, _ in entries:
|
||||
source.unlink()
|
||||
return {"status":"migrated", "state_dir":str(self.state), "files":len(entries)}
|
||||
|
||||
|
||||
def private_directory(path, boundary):
|
||||
path, boundary = Path(path), Path(boundary)
|
||||
if not path.is_relative_to(boundary):
|
||||
raise CredentialError("Private state directory is outside its user data boundary.")
|
||||
for parent in (path, *path.parents):
|
||||
if parent.is_symlink():
|
||||
raise CredentialError("Private Napseer state must not use symlink directories.")
|
||||
if parent == boundary: break
|
||||
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
for parent in (path, *path.parents):
|
||||
parent.chmod(0o700)
|
||||
if parent == boundary: break
|
||||
|
||||
|
||||
def file_fingerprint(path):
|
||||
digest = hashlib.sha256()
|
||||
fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
||||
with os.fdopen(fd, "rb") as stream:
|
||||
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
|
||||
raise CredentialError("Migration requires regular files.")
|
||||
size = 0
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
digest.update(chunk); size += len(chunk)
|
||||
return size, digest.hexdigest()
|
||||
|
||||
|
||||
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.workspace = WorkspacePaths(Path(state_dir).parent)
|
||||
self.state_dir = self.workspace.state
|
||||
self.legacy = Path(legacy_path or self.workspace.legacy / "auth.json").expanduser().absolute()
|
||||
self.user = user_data_dir() / "credentials" / "default.json"
|
||||
self.project = self.workspace.project_credentials
|
||||
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:
|
||||
path = Path(explicit).expanduser().absolute()
|
||||
if path.is_symlink():
|
||||
raise CredentialError("Explicit credentials must not be a symbolic link.")
|
||||
if path.resolve().is_relative_to(self.workspace.root) and self.workspace.root != Path.home().resolve():
|
||||
raise CredentialError("NAPSEER_AUTH_FILE must be outside the checkout; migrate private state first.")
|
||||
return path, "explicit"
|
||||
if self.legacy != self.workspace.legacy / "auth.json":
|
||||
if self.legacy.resolve().is_relative_to(self.workspace.root) and self.workspace.root != Path.home().resolve():
|
||||
raise CredentialError("Explicit credentials must be outside the checkout; migrate private state first.")
|
||||
return self.legacy, "explicit"
|
||||
self.workspace.require_migrated()
|
||||
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"
|
||||
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):
|
||||
self.workspace.migrate()
|
||||
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 == "explicit":
|
||||
atomic_json(path, data)
|
||||
return
|
||||
credentials = {k: v for k, v in data.items() if k in CREDENTIAL_KEYS}
|
||||
if source == "project":
|
||||
# Preserve project-bound worker/recovery/key metadata during
|
||||
# refresh after legacy migration. Profile selection stays local
|
||||
# to the worktree and must not change sibling worktrees.
|
||||
credentials = {k: v for k, v in data.items() if k != "credential_profile"}
|
||||
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
|
||||
Reference in New Issue
Block a user