Move private runtime state outside checkouts and isolate worktrees

This commit is contained in:
HumbleTurtle
2026-09-21 13:19:27 -03:00
parent 2a78262754
commit c427e2432a
12 changed files with 506 additions and 88 deletions
+21 -3
View File
@@ -28,9 +28,15 @@ Current state:
- `nap auth login` stores the shared account session in the user data folder - `nap auth login` stores the shared account session in the user data folder
(`~/.local/share/napseer/credentials/default.json` on Linux; platform user data directories (`~/.local/share/napseer/credentials/default.json` on Linux; platform user data directories
on macOS/Windows). `NAPSEER_USER_DATA_DIR` overrides that location. on macOS/Windows). `NAPSEER_USER_DATA_DIR` overrides that location.
`nap auth login --project` selects a local override. Legacy repository `nap auth login --project` selects a repository override shared by its Git
credentials remain an explicit override until `nap auth migrate` moves them. worktrees. Separate repositories retain separate overrides.
Gateway identities remain separate in `.napseer/gateway-auth.json`. - Private state lives entirely in user data. `credentials/` holds login grants;
`repositories/<id>/` holds the shared signing key; `workspaces/<id>/` holds
each worktree's selection, gateway identity, vault, index, locks, and logs.
Git common-directory identity groups related worktrees; each worktree's Git
directory identifies its runtime state, including after `git worktree move`.
Nested project locators keep their own namespace. Ordinary directories use
their canonical absolute path. Separate clones do not share project overrides.
- `.napseer/project.json` is the commit-safe project locator. It contains only - `.napseer/project.json` is the commit-safe project locator. It contains only
the schema, API origin, project UUID, and slug; it never contains tokens, the schema, API origin, project UUID, and slug; it never contains tokens,
account or worker identity, encryption state, keys, passphrases, or claim account or worker identity, encryption state, keys, passphrases, or claim
@@ -42,6 +48,18 @@ Current state:
Backend discovery publishes the release manifest and exact public source Backend discovery publishes the release manifest and exact public source
revision used for each bundle. revision used for each bundle.
For an existing checkout, stop older MCP clients and local gateway/UI processes,
update the runtime, and run `nap auth migrate` once from that checkout. This moves
private files out of `.napseer`, preserving the public locator and ignore file.
Migration checks running gateway PIDs, copies and verifies every file before
removing originals, and refuses conflicting destinations. An interrupted copy
can be retried; it never selects a different login to hide a conflict. Restart
clients afterward. Migrate existing worktrees individually; conflicting older
credentials or signing keys must be reconciled explicitly, never overwritten.
`NAPSEER_USER_DATA_DIR` and explicit credential files must point outside the
checkout. There is no credential or private-state symlink inside the repository.
Configure Codex and other long-lived stdio clients to launch: Configure Codex and other long-lived stdio clients to launch:
```sh ```sh
+19 -13
View File
@@ -66,7 +66,7 @@ RUNTIME_SCRIPT_NAMES = (
"napseer_mcp_supervisor.py", "napseer_mcp_supervisor.py",
) )
MAX_SERVICE_LOG_BYTES = int(os.environ.get("NAPSEER_MAX_SERVICE_LOG_BYTES", str(5 * 1024 * 1024))) MAX_SERVICE_LOG_BYTES = int(os.environ.get("NAPSEER_MAX_SERVICE_LOG_BYTES", str(5 * 1024 * 1024)))
CLI_RELEASE_VERSION = "0.3.0" CLI_RELEASE_VERSION = "0.4.0"
CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-09-10" CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-09-10"
# Older installers have a fixed asset list and would omit the credentials module. # Older installers have a fixed asset list and would omit the credentials module.
# Reject them before activation; the published bootstrap performs the upgrade. # Reject them before activation; the published bootstrap performs the upgrade.
@@ -630,32 +630,34 @@ def run_wrapper(command_name, args):
def preferred_state_dir(): def preferred_state_dir():
return pathlib.Path.cwd() / ".napseer" from napseer_credentials import WorkspacePaths
return WorkspacePaths().state
def cwd_state_dir(): def cwd_state_dir():
preferred = preferred_state_dir() preferred = preferred_state_dir()
preferred.mkdir(exist_ok=True) preferred.mkdir(parents=True, exist_ok=True)
chmod_best_effort(preferred, 0o700) chmod_best_effort(preferred, 0o700)
return preferred return preferred
def state_dir_status(): def state_dir_status():
preferred = preferred_state_dir() preferred = preferred_state_dir()
from napseer_credentials import CredentialStore from napseer_credentials import CredentialStore, WorkspacePaths
try: auth_path, credential_source = CredentialStore(preferred).selected() try: auth_path, credential_source = CredentialStore(WorkspacePaths().legacy).selected()
except RuntimeError: auth_path, credential_source = CredentialStore(preferred).user, "invalid_selection" except RuntimeError: auth_path, credential_source = CredentialStore(WorkspacePaths().legacy).user, "invalid_selection"
return { return {
"credential_source": credential_source, "credential_source": credential_source,
"migration_required": bool(WorkspacePaths().migration_sources()),
"active_state_dir": str(preferred), "active_state_dir": str(preferred),
"active_auth_path": str(auth_path), "active_auth_path": str(auth_path),
"state_dir": str(preferred), "state_dir": str(preferred),
"auth_path": str(auth_path), "auth_path": str(auth_path),
"state_dir_exists": preferred.exists(), "state_dir_exists": preferred.exists(),
"state_dir_message": ( "state_dir_message": (
".napseer directory is active." "Private Napseer state is stored in user data."
if preferred.exists() if preferred.exists()
else ".napseer directory is not initialized." else "Private Napseer state is not initialized."
), ),
} }
@@ -862,6 +864,8 @@ def validate_cli_options(args, *, flags=(), value_options=(), command_label):
def start_service(kind, args): def start_service(kind, args):
from napseer_credentials import WorkspacePaths
WorkspacePaths().require_migrated()
wrapper = runtime_asset_path("napseer_mcp_server.py") wrapper = runtime_asset_path("napseer_mcp_server.py")
require_runtime_assets() require_runtime_assets()
existing = read_service_state(kind) existing = read_service_state(kind)
@@ -1094,7 +1098,7 @@ def print_command_help(command):
" repair Recover failed automatic credential renewal.\n" " repair Recover failed automatic credential renewal.\n"
" logout [--project] Revoke the selected login.\n" " logout [--project] Revoke the selected login.\n"
" login --project Store a separate project login in user data.\n" " login --project Store a separate project login in user data.\n"
" migrate Move legacy project credentials into user data.\n" " migrate Move legacy private state into user data after stopping older clients/gateways.\n"
" use-user Explicitly select the general login for this folder.\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" " api-key --env NAME Configure project access from a named environment variable.\n"
" keys list|create|revoke Manage named, expiring delegated access." " keys list|create|revoke Manage named, expiring delegated access."
@@ -1366,17 +1370,19 @@ def doctor_status():
auth = {} auth = {}
auth_error = None auth_error = None
try: try:
from napseer_credentials import CredentialStore, read_json from napseer_credentials import CredentialStore, WorkspacePaths, read_json
store=CredentialStore(preferred_state_dir()) store=CredentialStore(WorkspacePaths().legacy)
auth=store.read() auth=store.read()
locator=read_json(preferred_state_dir()/"project.json") locator=read_json(WorkspacePaths().locator)
if not auth.get("project_id"): auth["project_id"]=locator.get("project_id") if not auth.get("project_id"): auth["project_id"]=locator.get("project_id")
except RuntimeError: except RuntimeError:
auth_error="credential state is unreadable; run nap auth login or inspect the selected override" auth_error="credential state is unreadable; run nap auth login or inspect the selected override"
missing = runtime_assets_missing() missing = runtime_assets_missing()
issues = [] issues = []
setup = [] setup = []
if auth_error: if state.get("migration_required"):
issues.append({"code": "state_migration_required", "message": "Stop older MCP clients and local gateways, then migrate private state into user data.", "next": "nap auth migrate"})
elif auth_error:
issues.append({"code": "auth_file_invalid", "message": auth_error, "next": "nap auth repair"}) issues.append({"code": "auth_file_invalid", "message": auth_error, "next": "nap auth repair"})
elif not auth_path.exists(): elif not auth_path.exists():
setup.append({ setup.append({
+212 -19
View File
@@ -8,6 +8,7 @@ import json
import os import os
from pathlib import Path from pathlib import Path
import stat import stat
import subprocess
import sys import sys
import tempfile import tempfile
import threading import threading
@@ -34,12 +35,201 @@ _held = threading.local()
def user_data_dir(): def user_data_dir():
if os.environ.get("NAPSEER_USER_DATA_DIR"): if os.environ.get("NAPSEER_USER_DATA_DIR"):
return Path(os.environ["NAPSEER_USER_DATA_DIR"]).expanduser().absolute() 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": if sys.platform == "win32":
return Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) / "napseer" return Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) / "napseer"
if sys.platform == "darwin": if sys.platform == "darwin":
return Path.home() / "Library" / "Application Support" / "napseer" return Path.home() / "Library" / "Application Support" / "napseer"
return Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "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): def read_json(path):
@@ -137,11 +327,11 @@ def credential_lock(path, timeout=30):
class CredentialStore: class CredentialStore:
def __init__(self, state_dir, legacy_path=None): def __init__(self, state_dir, legacy_path=None):
self.state_dir = Path(state_dir) self.workspace = WorkspacePaths(Path(state_dir).parent)
self.legacy = Path(legacy_path or self.state_dir / "auth.json") 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.user = user_data_dir() / "credentials" / "default.json"
project_key = hashlib.sha256(str(self.state_dir.parent.resolve()).encode()).hexdigest() self.project = self.workspace.project_credentials
self.project = user_data_dir() / "credentials" / "projects" / f"{project_key}.json"
self.selection = self.state_dir / "state.json" self.selection = self.state_dir / "state.json"
def selected(self): def selected(self):
@@ -149,15 +339,21 @@ class CredentialStore:
if bound: return bound if bound: return bound
explicit = os.environ.get("NAPSEER_AUTH_FILE") explicit = os.environ.get("NAPSEER_AUTH_FILE")
if explicit: if explicit:
return Path(explicit).expanduser().absolute(), "explicit" path = Path(explicit).expanduser().absolute()
if self.legacy != self.state_dir / "auth.json": 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" return self.legacy, "explicit"
self.workspace.require_migrated()
if read_json(self.selection).get("credential_profile") == "user": if read_json(self.selection).get("credential_profile") == "user":
return self.user, "user" return self.user, "user"
if self.project.exists() or self.project.is_symlink(): if self.project.exists() or self.project.is_symlink():
return self.project, "project" return self.project, "project"
if self.legacy.exists() or self.legacy.is_symlink():
return self.legacy, "legacy_project"
return self.user, "user" return self.user, "user"
@contextmanager @contextmanager
@@ -185,13 +381,7 @@ class CredentialStore:
return self.user return self.user
def migrate(self): def migrate(self):
with credential_lock(self.legacy), credential_lock(self.project): self.workspace.migrate()
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 return self.project
def read(self): def read(self):
@@ -205,12 +395,15 @@ class CredentialStore:
def write(self, data): def write(self, data):
path, source = self.selected() path, source = self.selected()
with credential_lock(path): with credential_lock(path):
if source in {"legacy_project", "explicit"}: if source == "explicit":
atomic_json(path, data) atomic_json(path, data)
return return
credentials = {k: v for k, v in data.items() if k in CREDENTIAL_KEYS} credentials = {k: v for k, v in data.items() if k in CREDENTIAL_KEYS}
if source == "project": if source == "project":
credentials.update({k: v for k, v in data.items() if k in SELECTION_KEYS}) # 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) current = read_json(path)
# Reading project metadata must not churn or overwrite shared # Reading project metadata must not churn or overwrite shared
# credentials, including after another process logged out. # credentials, including after another process logged out.
+71 -38
View File
@@ -47,7 +47,7 @@ _SCRIPT_DIR = pathlib.Path(__file__).parent
if str(_SCRIPT_DIR) not in sys.path: if str(_SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPT_DIR)) sys.path.insert(0, str(_SCRIPT_DIR))
from napseer_credentials import CredentialStore, credential_lock, CredentialError from napseer_credentials import CredentialStore, credential_lock, CredentialError, WorkspacePaths
class SafeToolError(RuntimeError): class SafeToolError(RuntimeError):
def __init__(self, code, message, *, status=None, service_code=None): def __init__(self, code, message, *, status=None, service_code=None):
@@ -195,10 +195,7 @@ class RelaySocketClosed(RuntimeError):
def local_state_dir(): def local_state_dir():
project_root = os.environ.get("NAPSEER_PROJECT_ROOT") return WorkspacePaths().state
if project_root:
return pathlib.Path(project_root).expanduser().resolve() / ".napseer"
return pathlib.Path.cwd() / ".napseer"
def state_dir_status_payload(): def state_dir_status_payload():
@@ -207,7 +204,10 @@ def state_dir_status_payload():
"active_state_dir": str(active), "active_state_dir": str(active),
"state_dir": str(active), "state_dir": str(active),
"state_dir_exists": active.exists(), "state_dir_exists": active.exists(),
"state_dir_message": ".napseer directory is active.", "state_dir_message": "Private Napseer state is stored in user data.",
"project_root": str(WORKSPACE.root),
"project_locator_path": str(WORKSPACE.locator),
"migration_required": bool(WORKSPACE.migration_sources()),
} }
@@ -318,8 +318,10 @@ def send_telemetry_event_async(event, component, outcome="success", **fields):
).start() ).start()
AUTH_DIR = local_state_dir() WORKSPACE = WorkspacePaths()
AUTH_PATH = pathlib.Path(os.environ.get("NAPSEER_AUTH_FILE", AUTH_DIR / "auth.json")).expanduser().resolve() PROJECT_DIR = WORKSPACE.legacy
AUTH_DIR = WORKSPACE.state
AUTH_PATH = pathlib.Path(os.environ.get("NAPSEER_AUTH_FILE", PROJECT_DIR / "auth.json")).expanduser().absolute()
PROJECT_LOCATOR_SCHEMA = "napseer.project.v1" PROJECT_LOCATOR_SCHEMA = "napseer.project.v1"
PROJECT_LOCATOR_FILENAME = "project.json" PROJECT_LOCATOR_FILENAME = "project.json"
PROJECT_LOCATOR_FIELDS = {"schema", "base_url", "project_id", "project_slug"} PROJECT_LOCATOR_FIELDS = {"schema", "base_url", "project_id", "project_slug"}
@@ -333,7 +335,7 @@ CONTAINER_IDENTITY_PATH = AUTH_DIR / "container-identity.json"
TELEMETRY_PATH = AUTH_DIR / "telemetry.json" TELEMETRY_PATH = AUTH_DIR / "telemetry.json"
INDEX_PATH = AUTH_DIR / "index.sqlite" INDEX_PATH = AUTH_DIR / "index.sqlite"
INDEX_LOCK_PATH = AUTH_DIR / "index.lock" INDEX_LOCK_PATH = AUTH_DIR / "index.lock"
DEFAULT_KEY_PATH = AUTH_DIR / "id_ed25519" DEFAULT_KEY_PATH = WORKSPACE.signing_key
SCRIPT_PATH = pathlib.Path(__file__).resolve() SCRIPT_PATH = pathlib.Path(__file__).resolve()
SCRIPT_NAME = SCRIPT_PATH.name SCRIPT_NAME = SCRIPT_PATH.name
SCRIPT_DEPENDENCY_NAMES = ("napseer_spake2.py", "napseer_credentials.py") SCRIPT_DEPENDENCY_NAMES = ("napseer_spake2.py", "napseer_credentials.py")
@@ -815,7 +817,7 @@ def read_local_vault_key(create=False):
return value return value
if not create: if not create:
return None return None
AUTH_DIR.mkdir(exist_ok=True) AUTH_DIR.mkdir(parents=True, exist_ok=True)
chmod_best_effort(AUTH_DIR, 0o700) chmod_best_effort(AUTH_DIR, 0o700)
value = base64.b64encode(secrets.token_bytes(32)).decode("ascii") value = base64.b64encode(secrets.token_bytes(32)).decode("ascii")
LOCAL_VAULT_KEY_PATH.write_text(value + "\n", encoding="utf-8") LOCAL_VAULT_KEY_PATH.write_text(value + "\n", encoding="utf-8")
@@ -828,7 +830,15 @@ def vault_exists():
def credential_store(): def credential_store():
return CredentialStore(AUTH_DIR, AUTH_PATH) return CredentialStore(PROJECT_DIR, AUTH_PATH)
def active_auth_path():
store = credential_store()
try:
return store.selected()[0]
except CredentialError:
return store.project # Destination only; status also reports migration_required.
def load_public_auth_file(): def load_public_auth_file():
@@ -850,8 +860,8 @@ def write_public_auth(data):
def project_locator_path(): def project_locator_path():
"""Return the repository-visible project locator beside private auth state.""" """Return the only repository-visible project data: the public locator."""
return AUTH_DIR / PROJECT_LOCATOR_FILENAME return PROJECT_DIR / PROJECT_LOCATOR_FILENAME
def validate_project_locator(data): def validate_project_locator(data):
@@ -1081,7 +1091,7 @@ def write_vault(passphrase, payload):
key = _derive_vault_key(passphrase, salt, VAULT_KDF_ITERATIONS) key = _derive_vault_key(passphrase, salt, VAULT_KDF_ITERATIONS)
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"))
AUTH_DIR.mkdir(exist_ok=True) AUTH_DIR.mkdir(parents=True, exist_ok=True)
chmod_best_effort(AUTH_DIR, 0o700) chmod_best_effort(AUTH_DIR, 0o700)
VAULT_PATH.write_text( VAULT_PATH.write_text(
json.dumps( json.dumps(
@@ -1140,7 +1150,7 @@ def derive_gateway_relay_secret(passphrase):
def write_gateway_relay_secret(passphrase): def write_gateway_relay_secret(passphrase):
AUTH_DIR.mkdir(exist_ok=True) AUTH_DIR.mkdir(parents=True, exist_ok=True)
chmod_best_effort(AUTH_DIR, 0o700) chmod_best_effort(AUTH_DIR, 0o700)
GATEWAY_RELAY_SECRET_PATH.write_text( GATEWAY_RELAY_SECRET_PATH.write_text(
json.dumps( json.dumps(
@@ -1196,7 +1206,7 @@ def write_gateway_relay_state(**updates):
current.update({key: value for key, value in updates.items() if value is not None}) current.update({key: value for key, value in updates.items() if value is not None})
current["schema"] = GATEWAY_RELAY_STATE_SCHEMA current["schema"] = GATEWAY_RELAY_STATE_SCHEMA
current["updated_at"] = iso_now() current["updated_at"] = iso_now()
AUTH_DIR.mkdir(exist_ok=True) AUTH_DIR.mkdir(parents=True, exist_ok=True)
chmod_best_effort(AUTH_DIR, 0o700) chmod_best_effort(AUTH_DIR, 0o700)
GATEWAY_RELAY_STATE_PATH.write_text(json.dumps(current, indent=2) + "\n", encoding="utf-8") GATEWAY_RELAY_STATE_PATH.write_text(json.dumps(current, indent=2) + "\n", encoding="utf-8")
chmod_best_effort(GATEWAY_RELAY_STATE_PATH, 0o600) chmod_best_effort(GATEWAY_RELAY_STATE_PATH, 0o600)
@@ -1325,7 +1335,7 @@ def ensure_container_uuid():
"container_uuid": value, "container_uuid": value,
"created_at": iso_now(), "created_at": iso_now(),
} }
AUTH_DIR.mkdir(exist_ok=True) AUTH_DIR.mkdir(parents=True, exist_ok=True)
chmod_best_effort(AUTH_DIR, 0o700) chmod_best_effort(AUTH_DIR, 0o700)
CONTAINER_IDENTITY_PATH.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") CONTAINER_IDENTITY_PATH.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
chmod_best_effort(CONTAINER_IDENTITY_PATH, 0o600) chmod_best_effort(CONTAINER_IDENTITY_PATH, 0o600)
@@ -1429,7 +1439,8 @@ def gateway_status():
"status": status, "status": status,
"message": message, "message": message,
**state_dir_status_payload(), **state_dir_status_payload(),
"auth_path": str(AUTH_PATH), "auth_path": str(active_auth_path()),
"state_dir": str(AUTH_DIR),
"log_path": str(AUTH_DIR / "gateway.log"), "log_path": str(AUTH_DIR / "gateway.log"),
"container_uuid": read_container_uuid(), "container_uuid": read_container_uuid(),
"vault_path": str(VAULT_PATH), "vault_path": str(VAULT_PATH),
@@ -1610,7 +1621,7 @@ def clean_terminal_name(value, fallback="terminal"):
def default_gateway_session_name(): def default_gateway_session_name():
source = AUTH.get("project_slug") or AUTH.get("project_name") or pathlib.Path.cwd().name or "project" source = AUTH.get("project_slug") or AUTH.get("project_name") or pathlib.Path.cwd().name or "project"
return f"napseer-{clean_tmux_name(source, 'project')}" return f"napseer-{clean_tmux_name(source, 'project')}-{WORKSPACE.workspace_id[:10]}"
def normalize_gateway_command(value=None): def normalize_gateway_command(value=None):
@@ -2175,7 +2186,7 @@ def write_gateway_schedules_state(state):
state["schema"] = GATEWAY_SCHEDULES_SCHEMA state["schema"] = GATEWAY_SCHEDULES_SCHEMA
state["version"] = 1 state["version"] = 1
state["updated_at"] = iso_now() state["updated_at"] = iso_now()
AUTH_DIR.mkdir(exist_ok=True) AUTH_DIR.mkdir(parents=True, exist_ok=True)
chmod_best_effort(AUTH_DIR, 0o700) chmod_best_effort(AUTH_DIR, 0o700)
GATEWAY_SCHEDULES_PATH.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8") GATEWAY_SCHEDULES_PATH.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
chmod_best_effort(GATEWAY_SCHEDULES_PATH, 0o600) chmod_best_effort(GATEWAY_SCHEDULES_PATH, 0o600)
@@ -6567,7 +6578,7 @@ INDEX_SCHEMA_READY = {}
def raw_index_file_lock(*, shared=False): def raw_index_file_lock(*, shared=False):
AUTH_DIR.mkdir(exist_ok=True) AUTH_DIR.mkdir(parents=True, exist_ok=True)
chmod_best_effort(AUTH_DIR, 0o700) chmod_best_effort(AUTH_DIR, 0o700)
handle = INDEX_LOCK_PATH.open("a+b") handle = INDEX_LOCK_PATH.open("a+b")
chmod_best_effort(INDEX_LOCK_PATH, 0o600) chmod_best_effort(INDEX_LOCK_PATH, 0o600)
@@ -6599,7 +6610,7 @@ def ensure_sqlite_column(conn, table, column, declaration):
def open_index_connection(): def open_index_connection():
AUTH_DIR.mkdir(exist_ok=True) AUTH_DIR.mkdir(parents=True, exist_ok=True)
chmod_best_effort(AUTH_DIR, 0o700) chmod_best_effort(AUTH_DIR, 0o700)
conn = sqlite3.connect(str(INDEX_PATH), timeout=30) conn = sqlite3.connect(str(INDEX_PATH), timeout=30)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
@@ -8526,13 +8537,14 @@ def _refresh_public_auth_state_locked():
def ensure_local_files(): def ensure_local_files():
AUTH_DIR.mkdir(exist_ok=True) AUTH_DIR.mkdir(parents=True, exist_ok=True)
chmod_best_effort(AUTH_DIR, 0o700) chmod_best_effort(AUTH_DIR, 0o700)
ensure_project_locator_ignore_policy() ensure_project_locator_ignore_policy()
key_path = pathlib.Path(AUTH["ssh_key_path"] or DEFAULT_KEY_PATH).expanduser() key_path = pathlib.Path(AUTH["ssh_key_path"] or DEFAULT_KEY_PATH).expanduser()
public_key_path = pathlib.Path(str(key_path) + ".pub") public_key_path = pathlib.Path(str(key_path) + ".pub")
with credential_lock(key_path):
if not key_path.exists(): if not key_path.exists():
key_path.parent.mkdir(exist_ok=True) key_path.parent.mkdir(parents=True, exist_ok=True)
chmod_best_effort(key_path.parent, 0o700) chmod_best_effort(key_path.parent, 0o700)
subprocess.run( subprocess.run(
["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key_path)], ["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key_path)],
@@ -9223,11 +9235,12 @@ def cli_project_init(args):
"status": "already_initialized", "status": "already_initialized",
"message": ( "message": (
"This folder is already initialized. " "This folder is already initialized. "
f"Project '{existing_slug}' is configured at {AUTH_PATH}." f"Project '{existing_slug}' is configured at {project_locator_path()}."
), ),
"project_id": DEFAULT_PROJECT_ID, "project_id": DEFAULT_PROJECT_ID,
"project_slug": existing_slug, "project_slug": existing_slug,
"auth_path": str(AUTH_PATH), "auth_path": str(active_auth_path()),
"state_dir": str(AUTH_DIR),
"project_locator": locator_result, "project_locator": locator_result,
} }
@@ -9240,7 +9253,8 @@ def cli_project_init(args):
), ),
"project_id": locator["project_id"], "project_id": locator["project_id"],
"project_slug": locator["project_slug"], "project_slug": locator["project_slug"],
"auth_path": str(AUTH_PATH), "auth_path": str(active_auth_path()),
"state_dir": str(AUTH_DIR),
"project_locator": project_locator_status(locator), "project_locator": project_locator_status(locator),
"next": {"attach_project": "nap project attach"}, "next": {"attach_project": "nap project attach"},
} }
@@ -9280,7 +9294,8 @@ def cli_project_init(args):
"to start a local gateway." "to start a local gateway."
), ),
"steps": steps, "steps": steps,
"auth_path": str(AUTH_PATH), "auth_path": str(active_auth_path()),
"state_dir": str(AUTH_DIR),
"project_locator": project_locator_status(), "project_locator": project_locator_status(),
} }
@@ -9289,7 +9304,8 @@ def cli_project_init(args):
"status": "initialized", "status": "initialized",
"message": "Folder initialized; existing project kept.", "message": "Folder initialized; existing project kept.",
"project_id": DEFAULT_PROJECT_ID, "project_id": DEFAULT_PROJECT_ID,
"auth_path": str(AUTH_PATH), "auth_path": str(active_auth_path()),
"state_dir": str(AUTH_DIR),
"project_locator": project_locator_status(), "project_locator": project_locator_status(),
"steps": steps, "steps": steps,
} }
@@ -9515,7 +9531,10 @@ def cli_gateway_service_call(route, payload=None):
# session endpoint existed. # session endpoint existed.
session = cli_gateway_service_json("GET", f"{base_url}/local-api/state") session = cli_gateway_service_json("GET", f"{base_url}/local-api/state")
service_auth_path = session.get("auth_path") service_auth_path = session.get("auth_path")
allowed_auth_paths = {AUTH_PATH.resolve(), GATEWAY_AUTH_PATH.resolve()} service_state_dir = session.get("state_dir")
if service_state_dir and pathlib.Path(service_state_dir).resolve() != AUTH_DIR.resolve():
raise RuntimeError("running gateway belongs to a different worktree")
allowed_auth_paths = {active_auth_path().resolve(), GATEWAY_AUTH_PATH.resolve()}
if service_auth_path and pathlib.Path(service_auth_path).resolve() not in allowed_auth_paths: if service_auth_path and pathlib.Path(service_auth_path).resolve() not in allowed_auth_paths:
raise RuntimeError("running gateway belongs to a different project folder") raise RuntimeError("running gateway belongs to a different project folder")
csrf_token = session.get("csrf_token") csrf_token = session.get("csrf_token")
@@ -14118,7 +14137,8 @@ def start_local_ui(args):
"csrf_token": csrf_token, "csrf_token": csrf_token,
"agent_id": AUTH.get("agent_id"), "agent_id": AUTH.get("agent_id"),
"token_expires_at": TOKEN_EXPIRES_AT, "token_expires_at": TOKEN_EXPIRES_AT,
"auth_path": str(AUTH_PATH), "auth_path": str(active_auth_path()),
"state_dir": str(AUTH_DIR),
"account_mode": AUTH.get("account_mode"), "account_mode": AUTH.get("account_mode"),
"auth_configured": bool(TOKEN), "auth_configured": bool(TOKEN),
"wrapper": { "wrapper": {
@@ -14211,7 +14231,8 @@ def start_local_ui(args):
"csrf_token": csrf_token, "csrf_token": csrf_token,
"agent_id": AUTH.get("agent_id"), "agent_id": AUTH.get("agent_id"),
"token_expires_at": TOKEN_EXPIRES_AT, "token_expires_at": TOKEN_EXPIRES_AT,
"auth_path": str(AUTH_PATH), "auth_path": str(active_auth_path()),
"state_dir": str(AUTH_DIR),
"account_mode": AUTH.get("account_mode"), "account_mode": AUTH.get("account_mode"),
"auth_configured": bool(TOKEN), "auth_configured": bool(TOKEN),
"wrapper": { "wrapper": {
@@ -14333,7 +14354,8 @@ def start_local_ui(args):
if parsed_path.path == "/local-api/session": if parsed_path.path == "/local-api/session":
self.send_json(200, { self.send_json(200, {
"csrf_token": csrf_token, "csrf_token": csrf_token,
"auth_path": str(AUTH_PATH), "auth_path": str(active_auth_path()),
"state_dir": str(AUTH_DIR),
"project_id": current_project_id(), "project_id": current_project_id(),
}) })
return return
@@ -14358,7 +14380,7 @@ def start_local_ui(args):
<html><head><meta charset="utf-8"><title>Napseer local manager</title> <html><head><meta charset="utf-8"><title>Napseer local manager</title>
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<style>body{{font-family:ui-sans-serif,system-ui;margin:0;background:#eef3ef;color:#101312}}main{{max-width:720px;margin:0 auto;padding:28px}}section{{border:1px solid #d7ded8;background:#fbfdfb;padding:20px}}label{{display:grid;gap:6px;margin:12px 0;color:#5d6661;font-weight:700}}input,textarea,select{{font:inherit;border:1px solid #9aa9a0;background:#fff;padding:10px}}button{{border:1px solid #073f3b;background:#073f3b;color:#fff;font-weight:800;padding:10px 14px}}code{{background:#e5ece7;padding:2px 5px}}</style></head> <style>body{{font-family:ui-sans-serif,system-ui;margin:0;background:#eef3ef;color:#101312}}main{{max-width:720px;margin:0 auto;padding:28px}}section{{border:1px solid #d7ded8;background:#fbfdfb;padding:20px}}label{{display:grid;gap:6px;margin:12px 0;color:#5d6661;font-weight:700}}input,textarea,select{{font:inherit;border:1px solid #9aa9a0;background:#fff;padding:10px}}button{{border:1px solid #073f3b;background:#073f3b;color:#fff;font-weight:800;padding:10px 14px}}code{{background:#e5ece7;padding:2px 5px}}</style></head>
<body><main><section><h1>Create this Napseer project</h1><p>This localhost UI is running before a project is configured for <code>{html.escape(str(AUTH_PATH))}</code>.</p> <body><main><section><h1>Create this Napseer project</h1><p>This localhost UI is running before a project is configured for <code>{html.escape(str(active_auth_path()))}</code>.</p>
<form method="post" action="/bootstrap-project"><input type="hidden" name="csrf_token" value="{html.escape(csrf_token)}"> <form method="post" action="/bootstrap-project"><input type="hidden" name="csrf_token" value="{html.escape(csrf_token)}">
<label>Project name<input name="name" required placeholder="{html.escape(default_project_name(default_project_slug()))}"></label> <label>Project name<input name="name" required placeholder="{html.escape(default_project_name(default_project_slug()))}"></label>
<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>
@@ -14432,7 +14454,7 @@ def start_local_ui(args):
</style></head> </style></head>
<body><main> <body><main>
<div class="topbar"><div class="brand"><span class="mark">N</span><span>Napseer local manager</span></div><nav><a href="#memory">Memory</a><a href="#schedules">Schedules</a><a href="#auth">Auth</a><a href="#feedback">Feedback</a><a href="#update">Update</a></nav></div> <div class="topbar"><div class="brand"><span class="mark">N</span><span>Napseer local manager</span></div><nav><a href="#memory">Memory</a><a href="#schedules">Schedules</a><a href="#auth">Auth</a><a href="#feedback">Feedback</a><a href="#update">Update</a></nav></div>
<section class="hero" id="overview"><h1>Project memory, local authority.</h1><p class="muted">This UI runs on <code>127.0.0.1</code>, reads <code>{html.escape(str(AUTH_PATH))}</code>, and publishes executable schedules with project auth.</p><div class="stats"> <section class="hero" id="overview"><h1>Project memory, local authority.</h1><p class="muted">This UI runs on <code>127.0.0.1</code>, reads <code>{html.escape(str(active_auth_path()))}</code>, and publishes executable schedules with project auth.</p><div class="stats">
<div class="stat"><b>Project</b><span class="mono">{html.escape(project_id)}</span></div> <div class="stat"><b>Project</b><span class="mono">{html.escape(project_id)}</span></div>
<div class="stat"><b>Agent</b><span class="mono">{html.escape(str(AUTH.get('agent_id') or 'unknown'))}</span></div> <div class="stat"><b>Agent</b><span class="mono">{html.escape(str(AUTH.get('agent_id') or 'unknown'))}</span></div>
<div class="stat"><b>Token expires</b><span>{html.escape(str(TOKEN_EXPIRES_AT or 'unknown'))}</span></div> <div class="stat"><b>Token expires</b><span>{html.escape(str(TOKEN_EXPIRES_AT or 'unknown'))}</span></div>
@@ -16971,6 +16993,8 @@ def safe_auth_refresh_error():
def safe_exception_text(exc): def safe_exception_text(exc):
if isinstance(exc, CredentialError):
return str(exc) # CredentialError messages are fixed, secret-free diagnostics.
if isinstance(exc, SafeToolError): if isinstance(exc, SafeToolError):
return exc.safe_message return exc.safe_message
return ( return (
@@ -17919,6 +17943,8 @@ def call_tool_impl(name, args):
"Unknown or unavailable MCP tool. Use `nap_apropos` to discover the canonical surface.", "Unknown or unavailable MCP tool. Use `nap_apropos` to discover the canonical surface.",
) )
canonical_name = COMPATIBILITY_TOOL_ALIASES.get(name, name) canonical_name = COMPATIBILITY_TOOL_ALIASES.get(name, name)
if canonical_name not in {"nap_whoami", "nap_contract", "nap_man", "nap_apropos"}:
WORKSPACE.require_migrated()
if canonical_name not in enabled_tool_names(): if canonical_name not in enabled_tool_names():
profile = tool_profile(canonical_name) profile = tool_profile(canonical_name)
raise SafeToolError( raise SafeToolError(
@@ -18250,7 +18276,8 @@ def handle(message):
"serverInfo": {"name": "napseer-local", "version": "0.1.6"}, "serverInfo": {"name": "napseer-local", "version": "0.1.6"},
"instructions": "Local authenticated MCP wrapper. It resolves user or project credentials and repository identity; agents should not handle secrets or pass project ids for normal tools.", "instructions": "Local authenticated MCP wrapper. It resolves user or project credentials and repository identity; agents should not handle secrets or pass project ids for normal tools.",
"napseer": { "napseer": {
"auth_path": str(AUTH_PATH), "auth_path": str(active_auth_path()),
"state_dir": str(AUTH_DIR),
"base_url": BASE_URL, "base_url": BASE_URL,
"has_token": bool(TOKEN), "has_token": bool(TOKEN),
"project_id": DEFAULT_PROJECT_ID, "project_id": DEFAULT_PROJECT_ID,
@@ -18390,6 +18417,9 @@ class McpRequestRuntime:
def cli_main(argv): def cli_main(argv):
command = argv[1] if len(argv) > 1 else "" command = argv[1] if len(argv) > 1 else ""
recovery = argv[1:] in (["auth", "migrate"], ["auth", "status"], ["project", "status"])
if command and not recovery and not any(arg in {"help", "--help", "-h"} for arg in argv[1:]) and command not in {"configure", "config", "update", "update-status"}:
WORKSPACE.require_migrated()
if command == "reindex": if command == "reindex":
args = argv[2:] args = argv[2:]
validate_cli_options( validate_cli_options(
@@ -18452,7 +18482,7 @@ def cli_main(argv):
nap auth logout [--project] nap auth logout [--project]
nap auth api-key --env VARIABLE_NAME nap auth api-key --env VARIABLE_NAME
nap auth keys list|create|revoke nap auth keys list|create|revoke
nap auth migrate Move legacy credentials to user data without broadening scope. nap auth migrate Move legacy private state to user data; stop older clients/gateways first.
nap auth use-user Explicitly use the general login in this repository. nap auth use-user Explicitly use the general login in this repository.
`login` signs in to the account only. It does not select a project. `login` signs in to the account only. It does not select a project.
@@ -18462,7 +18492,10 @@ Refresh is automatic during authenticated calls; use `repair` only for recovery.
return return
if subcommand in {"use-user","migrate"}: if subcommand in {"use-user","migrate"}:
validate_cli_options(args, command_label=f"nap auth {subcommand}") validate_cli_options(args, command_label=f"nap auth {subcommand}")
path = credential_store().use_user() if subcommand == "use-user" else credential_store().migrate() if subcommand == "migrate":
print(json.dumps(WORKSPACE.migrate(), indent=2))
return
path = credential_store().use_user()
print(json.dumps({"status":"configured","auth_path":str(path),"message":"Credential selection updated. Existing project identity is preserved."},indent=2)); return print(json.dumps({"status":"configured","auth_path":str(path),"message":"Credential selection updated. Existing project identity is preserved."},indent=2)); return
if subcommand in {"login", "login-local", "operator-login"}: if subcommand in {"login", "login-local", "operator-login"}:
validate_cli_options( validate_cli_options(
+2 -2
View File
@@ -3,8 +3,8 @@ import pytest
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def isolated_user_credentials(monkeypatch, tmp_path): def isolated_user_credentials(monkeypatch, tmp_path, tmp_path_factory):
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
monkeypatch.setenv("NAPSEER_USER_DATA_DIR", str(tmp_path / "user-data")) monkeypatch.setenv("NAPSEER_USER_DATA_DIR", str(tmp_path_factory.mktemp("napseer-user")))
for name in ("NAPSEER_API_KEY", "NAPSEER_TOKEN", "NAPSEER_REFRESH_TOKEN", "NAPSEER_AUTH_FILE", "NAPSEER_PROJECT_ROOT", "NAPSEER_PROJECT_ID", "NAPSEER_BASE_URL"): for name in ("NAPSEER_API_KEY", "NAPSEER_TOKEN", "NAPSEER_REFRESH_TOKEN", "NAPSEER_AUTH_FILE", "NAPSEER_PROJECT_ROOT", "NAPSEER_PROJECT_ID", "NAPSEER_BASE_URL"):
monkeypatch.delenv(name, raising=False) monkeypatch.delenv(name, raising=False)
+3 -1
View File
@@ -48,13 +48,15 @@ def test_stdin_bootstrap_installs_verified_runtime_and_preserves_credentials(tmp
server = ThreadingHTTPServer(("127.0.0.1", 0), Provider) server = ThreadingHTTPServer(("127.0.0.1", 0), Provider)
thread = threading.Thread(target=server.serve_forever, daemon=True) thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start() thread.start()
workspace = tmp_path / "workspace"
workspace.mkdir()
user_data = tmp_path / "user-data" user_data = tmp_path / "user-data"
user_data.mkdir(exist_ok=True) user_data.mkdir(exist_ok=True)
auth = user_data / "auth.json" auth = user_data / "auth.json"
auth.write_text('{"refresh_token":"synthetic-preserved"}') auth.write_text('{"refresh_token":"synthetic-preserved"}')
env = {"PATH": os.environ["PATH"], "NAPSEER_HOME": str(tmp_path / "runtime"), env = {"PATH": os.environ["PATH"], "NAPSEER_HOME": str(tmp_path / "runtime"),
"NAPSEER_BIN_DIR": str(tmp_path / "bin"), "NAPSEER_USER_DATA_DIR": str(user_data), "NAPSEER_BIN_DIR": str(tmp_path / "bin"), "NAPSEER_USER_DATA_DIR": str(user_data),
"NAPSEER_PROJECT_ROOT": str(tmp_path), "NAPSEER_BASE_URL": f"http://127.0.0.1:{server.server_port}"} "NAPSEER_PROJECT_ROOT": str(workspace), "NAPSEER_BASE_URL": f"http://127.0.0.1:{server.server_port}"}
try: try:
result = subprocess.run([sys.executable, "-"], input=(source / "nap_install.py").read_text(), result = subprocess.run([sys.executable, "-"], input=(source / "nap_install.py").read_text(),
cwd=tmp_path, env=env, capture_output=True, text=True, timeout=30) cwd=tmp_path, env=env, capture_output=True, text=True, timeout=30)
+4
View File
@@ -192,6 +192,10 @@ def test_doctor_reports_presence_without_exposing_values(tmp_path, monkeypatch):
}, },
) )
from napseer_credentials import CredentialStore
store = CredentialStore(state_dir)
store.migrate()
auth_path = store.project
result = module.doctor_status() result = module.doctor_status()
serialized = json.dumps(result) serialized = json.dumps(result)
+1 -1
View File
@@ -167,7 +167,7 @@ def test_mutating_tools_are_serialized_while_reads_can_overlap(monkeypatch):
def test_concurrent_first_reads_share_one_project_bootstrap(tmp_path, monkeypatch): def test_concurrent_first_reads_share_one_project_bootstrap(tmp_path, monkeypatch):
mod = load_module() mod = load_module()
mod.TOKEN = "synthetic-configured-access" mod.TOKEN = "synthetic-configured-access"
mod.AUTH_PATH = tmp_path / "missing-auth.json" mod.AUTH_PATH = mod.AUTH_DIR / "missing-auth.json"
mod.DEFAULT_PROJECT_ID = None mod.DEFAULT_PROJECT_ID = None
monkeypatch.setattr(mod, "refresh_public_auth_state", lambda: None) monkeypatch.setattr(mod, "refresh_public_auth_state", lambda: None)
calls = [] calls = []
+1
View File
@@ -26,6 +26,7 @@ def load_module():
def configure_state(mod, root): def configure_state(mod, root):
state_dir = pathlib.Path(root) / ".napseer" state_dir = pathlib.Path(root) / ".napseer"
mod.AUTH_DIR = state_dir mod.AUTH_DIR = state_dir
mod.PROJECT_DIR = state_dir
mod.AUTH_PATH = state_dir / "auth.json" mod.AUTH_PATH = state_dir / "auth.json"
mod.BASE_URL = "https://api.example.test" mod.BASE_URL = "https://api.example.test"
mod.TOKEN = None mod.TOKEN = None
+2 -2
View File
@@ -29,11 +29,11 @@ SMOKES = [
@pytest.mark.parametrize("filename", SMOKES) @pytest.mark.parametrize("filename", SMOKES)
def test_standalone_smoke(filename): def test_standalone_smoke(filename, tmp_path):
test_dir = pathlib.Path(__file__).resolve().parent test_dir = pathlib.Path(__file__).resolve().parent
result = subprocess.run( result = subprocess.run(
[sys.executable, str(test_dir / filename)], [sys.executable, str(test_dir / filename)],
cwd=test_dir.parent, cwd=tmp_path,
capture_output=True, capture_output=True,
text=True, text=True,
timeout=90, timeout=90,
+3 -2
View File
@@ -13,7 +13,7 @@ import pytest
SCRIPT = Path(__file__).resolve().parents[1] / "resources/scripts/napseer_mcp_server.py" SCRIPT = Path(__file__).resolve().parents[1] / "resources/scripts/napseer_mcp_server.py"
sys.path.insert(0, str(SCRIPT.parent)) sys.path.insert(0, str(SCRIPT.parent))
from napseer_credentials import CredentialStore, atomic_json, read_json from napseer_credentials import CredentialStore, CredentialError, atomic_json, read_json
@pytest.fixture @pytest.fixture
@@ -204,7 +204,8 @@ def test_legacy_migration_preserves_authority_and_general_selection_is_explicit(
store=seed(tmp_path,url) store=seed(tmp_path,url)
legacy={"base_url":url,"token":"legacy-project","account_mode":"operator_project","project_id":"test-project"} legacy={"base_url":url,"token":"legacy-project","account_mode":"operator_project","project_id":"test-project"}
atomic_json(store.legacy,legacy) atomic_json(store.legacy,legacy)
assert store.selected()[1]=="legacy_project" with pytest.raises(CredentialError, match="needs migration"):
store.selected()
store.migrate() store.migrate()
assert not store.legacy.exists() assert not store.legacy.exists()
assert store.read()["token"]=="legacy-project" assert store.read()["token"]=="legacy-project"
+160
View File
@@ -0,0 +1,160 @@
"""Real Git worktrees and worker processes exercise private-state ownership."""
import json
import os
from pathlib import Path
import subprocess
import sys
import pytest
SCRIPTS = Path(__file__).resolve().parents[1] / 'resources/scripts'
sys.path.insert(0, str(SCRIPTS))
from napseer_credentials import CredentialStore, atomic_json, read_json
from test_user_credentials import provider, worker, outcome
@pytest.fixture
def worktrees(tmp_path):
main, other = tmp_path/'main', tmp_path/'other'
main.mkdir()
def git(*args):
subprocess.run(['git', '-C', str(main), *args], check=True, capture_output=True)
git('init', '-q')
atomic_json(main/'.napseer/project.json', {'schema':'napseer.project.v1',
'base_url':'https://api.example.test', 'project_id':'33333333-3333-3333-3333-333333333333',
'project_slug':'shared'})
git('add', '.napseer/project.json')
git('-c', 'user.name=Test', '-c', 'user.email=test@example.test', 'commit', '-qm', 'locator')
git('worktree', 'add', '-qb', 'other', str(other))
return main, other
def test_worker_keeps_private_paths_outside_checkout_and_stable_from_subdirectory(worktrees):
main, other = worktrees
sub = main/'nested'; sub.mkdir()
expression = "{'state':str(m.local_state_dir()),'key':str(m.DEFAULT_KEY_PATH),'locator':str(m.project_locator_path())}"
a,b,c = [outcome(worker(root, expression))['result'] for root in (main,other,sub)]
assert not Path(a['state']).is_relative_to(main)
assert not Path(b['state']).is_relative_to(other)
assert a['state'] == c['state']
assert a['state'] != b['state']
assert a['key'] == b['key']
assert a['locator'] == str(main/'.napseer/project.json')
assert c['locator'] == a['locator']
def test_project_login_is_shared_across_worktrees_and_refreshes_once(worktrees, provider):
main, other = worktrees
url, state = provider
first, second = [CredentialStore(root/'.napseer') for root in (main,other)]
first.login({'base_url':url,'token':'old-access','refresh_token':'old-refresh',
'account_mode':'operator_project','oauth_client_id':'nap-cli'}, project=True)
assert first.project == second.project
assert first.selection != second.selection
assert not first.selection.is_relative_to(main)
requests=[worker(root, "m.request_json('GET','/v1/projects')") for root in (main,other)]
assert all(outcome(p)['ok'] for p in requests)
assert state['refreshes'] == 1
assert outcome(worker(other, "m.request_json('GET','/v1/projects')"))['ok']
assert state['refreshes'] == 1
def test_migration_preserves_keys_and_locator_and_refuses_conflicting_destination(worktrees):
from napseer_credentials import WorkspacePaths, CredentialError
main, _ = worktrees
paths = WorkspacePaths(main)
old = main/'.napseer'
locator=(old/'project.json').read_bytes()
(old/'vault.local-key').write_bytes(b'synthetic-vault-key\n')
(old/'vault.json').write_bytes(b'{"synthetic":"ciphertext"}\n')
(old/'id_ed25519').write_bytes(b'synthetic-signing-key\n')
atomic_json(old/'auth.json', {'token':'synthetic-token','ssh_key_path':str(old/'id_ed25519')})
paths.state.mkdir(parents=True)
(paths.state/'vault.local-key').write_bytes(b'conflicting-key')
with pytest.raises(CredentialError): paths.migrate()
assert (old/'vault.local-key').read_bytes() == b'synthetic-vault-key\n'
assert (old/'auth.json').exists()
(paths.state/'vault.local-key').unlink()
paths.migrate()
assert (paths.state/'vault.local-key').read_bytes() == b'synthetic-vault-key\n'
assert paths.signing_key.read_bytes() == b'synthetic-signing-key\n'
assert read_json(CredentialStore(old).project)['ssh_key_path'] == str(paths.signing_key)
assert (old/'project.json').read_bytes() == locator
assert set(p.name for p in old.iterdir()) == {'project.json'}
paths.migrate() # retry after a completed migration is harmless
assert paths.signing_key.stat().st_mode & 0o777 == 0o600
def test_other_repositories_do_not_share_project_override(tmp_path):
first=CredentialStore(tmp_path/'one/.napseer')
second=CredentialStore(tmp_path/'two/.napseer')
first.login({'token':'synthetic-project-token'}, project=True)
assert first.project != second.project
assert not second.read().get('token')
def test_linked_worktree_move_keeps_external_state_identity(worktrees):
from napseer_credentials import WorkspacePaths
main, other = worktrees
before = WorkspacePaths(other)
moved = other.with_name('moved')
subprocess.run(['git','-C',str(main),'worktree','move',str(other),str(moved)], check=True, capture_output=True)
after = WorkspacePaths(moved)
assert before.state == after.state
assert before.project_credentials == after.project_credentials
def test_concurrent_worktree_signing_key_creation_is_serialized(worktrees):
main, other = worktrees
processes = [worker(root, 'str(m.ensure_local_files())') for root in (main,other)]
results = [outcome(p) for p in processes]
assert all(r['ok'] for r in results)
assert results[0]['result'] == results[1]['result']
key = Path(results[0]['result'])
assert key.is_file() and Path(str(key)+'.pub').is_file()
assert key.stat().st_mode & 0o777 == 0o600
assert all(not (root/'.napseer/id_ed25519').exists() for root in (main,other))
def test_interrupted_migration_preserves_originals_and_retries(worktrees, monkeypatch):
from napseer_credentials import WorkspacePaths
main, _ = worktrees
paths=WorkspacePaths(main)
for name in ('vault.local-key','vault.json'):(paths.legacy/name).write_bytes(b'synthetic-'+name.encode())
original_link=os.link
calls=[]
def interrupted(source,destination):
calls.append(destination)
if len(calls)==2:raise OSError('synthetic interrupted publication')
return original_link(source,destination)
monkeypatch.setattr(os,'link',interrupted)
with pytest.raises(OSError):paths.migrate()
assert all((paths.legacy/name).exists() for name in ('vault.local-key','vault.json'))
monkeypatch.setattr(os,'link',original_link)
paths.migrate()
assert all((paths.state/name).read_bytes()==b'synthetic-'+name.encode() for name in ('vault.local-key','vault.json'))
def test_cli_migration_reports_running_gateway_and_recovers(worktrees):
main, _ = worktrees
atomic_json(main/'.napseer/gateway.json', {'pid':os.getpid()})
key=main/'.napseer/vault.local-key';key.write_text('synthetic-key')
env=dict(os.environ,NAPSEER_PROJECT_ROOT=str(main),NAPSEER_TELEMETRY='0')
command=[sys.executable,str(SCRIPTS/'napseer_mcp_server.py'),'auth','migrate']
result=subprocess.run(command,cwd=main,env=env,capture_output=True,text=True,timeout=15)
assert result.returncode!=0 and 'Stop the running' in result.stderr
assert 'synthetic-key' not in result.stderr and key.exists()
atomic_json(main/'.napseer/gateway.json', {'pid':0})
result=subprocess.run(command,cwd=main,env=env,capture_output=True,text=True,timeout=15)
assert result.returncode==0
report=json.loads(result.stdout)
assert report['status']=='migrated'
assert (Path(report['state_dir'])/'vault.local-key').read_text()=='synthetic-key'
assert not key.exists()
def test_private_state_cannot_be_redirected_into_checkout(worktrees, monkeypatch):
from napseer_credentials import WorkspacePaths, CredentialError
main,_=worktrees
monkeypatch.setenv('NAPSEER_USER_DATA_DIR',str(main/'private-state'))
with pytest.raises(CredentialError,match='outside'):WorkspacePaths(main)