Move private runtime state outside checkouts and isolate worktrees
This commit is contained in:
@@ -28,9 +28,15 @@ Current state:
|
||||
- `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
|
||||
on macOS/Windows). `NAPSEER_USER_DATA_DIR` overrides that location.
|
||||
`nap auth login --project` selects a local override. Legacy repository
|
||||
credentials remain an explicit override until `nap auth migrate` moves them.
|
||||
Gateway identities remain separate in `.napseer/gateway-auth.json`.
|
||||
`nap auth login --project` selects a repository override shared by its Git
|
||||
worktrees. Separate repositories retain separate overrides.
|
||||
- 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
|
||||
the schema, API origin, project UUID, and slug; it never contains tokens,
|
||||
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
|
||||
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:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -66,7 +66,7 @@ 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.3.0"
|
||||
CLI_RELEASE_VERSION = "0.4.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.
|
||||
@@ -630,32 +630,34 @@ def run_wrapper(command_name, args):
|
||||
|
||||
|
||||
def preferred_state_dir():
|
||||
return pathlib.Path.cwd() / ".napseer"
|
||||
from napseer_credentials import WorkspacePaths
|
||||
return WorkspacePaths().state
|
||||
|
||||
|
||||
def cwd_state_dir():
|
||||
preferred = preferred_state_dir()
|
||||
preferred.mkdir(exist_ok=True)
|
||||
preferred.mkdir(parents=True, exist_ok=True)
|
||||
chmod_best_effort(preferred, 0o700)
|
||||
return preferred
|
||||
|
||||
|
||||
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"
|
||||
from napseer_credentials import CredentialStore, WorkspacePaths
|
||||
try: auth_path, credential_source = CredentialStore(WorkspacePaths().legacy).selected()
|
||||
except RuntimeError: auth_path, credential_source = CredentialStore(WorkspacePaths().legacy).user, "invalid_selection"
|
||||
return {
|
||||
"credential_source": credential_source,
|
||||
"migration_required": bool(WorkspacePaths().migration_sources()),
|
||||
"active_state_dir": str(preferred),
|
||||
"active_auth_path": str(auth_path),
|
||||
"state_dir": str(preferred),
|
||||
"auth_path": str(auth_path),
|
||||
"state_dir_exists": preferred.exists(),
|
||||
"state_dir_message": (
|
||||
".napseer directory is active."
|
||||
"Private Napseer state is stored in user data."
|
||||
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):
|
||||
from napseer_credentials import WorkspacePaths
|
||||
WorkspacePaths().require_migrated()
|
||||
wrapper = runtime_asset_path("napseer_mcp_server.py")
|
||||
require_runtime_assets()
|
||||
existing = read_service_state(kind)
|
||||
@@ -1094,7 +1098,7 @@ def print_command_help(command):
|
||||
" 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"
|
||||
" 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"
|
||||
" api-key --env NAME Configure project access from a named environment variable.\n"
|
||||
" keys list|create|revoke Manage named, expiring delegated access."
|
||||
@@ -1366,17 +1370,19 @@ def doctor_status():
|
||||
auth = {}
|
||||
auth_error = None
|
||||
try:
|
||||
from napseer_credentials import CredentialStore, read_json
|
||||
store=CredentialStore(preferred_state_dir())
|
||||
from napseer_credentials import CredentialStore, WorkspacePaths, read_json
|
||||
store=CredentialStore(WorkspacePaths().legacy)
|
||||
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")
|
||||
except RuntimeError:
|
||||
auth_error="credential state is unreadable; run nap auth login or inspect the selected override"
|
||||
missing = runtime_assets_missing()
|
||||
issues = []
|
||||
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"})
|
||||
elif not auth_path.exists():
|
||||
setup.append({
|
||||
|
||||
@@ -8,6 +8,7 @@ import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
@@ -34,12 +35,201 @@ _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()
|
||||
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"
|
||||
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):
|
||||
@@ -137,11 +327,11 @@ def credential_lock(path, timeout=30):
|
||||
|
||||
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.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"
|
||||
project_key = hashlib.sha256(str(self.state_dir.parent.resolve()).encode()).hexdigest()
|
||||
self.project = user_data_dir() / "credentials" / "projects" / f"{project_key}.json"
|
||||
self.project = self.workspace.project_credentials
|
||||
self.selection = self.state_dir / "state.json"
|
||||
|
||||
def selected(self):
|
||||
@@ -149,15 +339,21 @@ class CredentialStore:
|
||||
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":
|
||||
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"
|
||||
if self.legacy.exists() or self.legacy.is_symlink():
|
||||
return self.legacy, "legacy_project"
|
||||
return self.user, "user"
|
||||
|
||||
@contextmanager
|
||||
@@ -185,14 +381,8 @@ class CredentialStore:
|
||||
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
|
||||
self.workspace.migrate()
|
||||
return self.project
|
||||
|
||||
def read(self):
|
||||
path, source = self.selected()
|
||||
@@ -205,12 +395,15 @@ class CredentialStore:
|
||||
def write(self, data):
|
||||
path, source = self.selected()
|
||||
with credential_lock(path):
|
||||
if source in {"legacy_project", "explicit"}:
|
||||
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":
|
||||
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)
|
||||
# Reading project metadata must not churn or overwrite shared
|
||||
# credentials, including after another process logged out.
|
||||
|
||||
@@ -47,7 +47,7 @@ _SCRIPT_DIR = pathlib.Path(__file__).parent
|
||||
if str(_SCRIPT_DIR) not in sys.path:
|
||||
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):
|
||||
def __init__(self, code, message, *, status=None, service_code=None):
|
||||
@@ -195,10 +195,7 @@ class RelaySocketClosed(RuntimeError):
|
||||
|
||||
|
||||
def local_state_dir():
|
||||
project_root = os.environ.get("NAPSEER_PROJECT_ROOT")
|
||||
if project_root:
|
||||
return pathlib.Path(project_root).expanduser().resolve() / ".napseer"
|
||||
return pathlib.Path.cwd() / ".napseer"
|
||||
return WorkspacePaths().state
|
||||
|
||||
|
||||
def state_dir_status_payload():
|
||||
@@ -207,7 +204,10 @@ def state_dir_status_payload():
|
||||
"active_state_dir": str(active),
|
||||
"state_dir": str(active),
|
||||
"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()
|
||||
|
||||
|
||||
AUTH_DIR = local_state_dir()
|
||||
AUTH_PATH = pathlib.Path(os.environ.get("NAPSEER_AUTH_FILE", AUTH_DIR / "auth.json")).expanduser().resolve()
|
||||
WORKSPACE = WorkspacePaths()
|
||||
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_FILENAME = "project.json"
|
||||
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"
|
||||
INDEX_PATH = AUTH_DIR / "index.sqlite"
|
||||
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_NAME = SCRIPT_PATH.name
|
||||
SCRIPT_DEPENDENCY_NAMES = ("napseer_spake2.py", "napseer_credentials.py")
|
||||
@@ -815,7 +817,7 @@ def read_local_vault_key(create=False):
|
||||
return value
|
||||
if not create:
|
||||
return None
|
||||
AUTH_DIR.mkdir(exist_ok=True)
|
||||
AUTH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
chmod_best_effort(AUTH_DIR, 0o700)
|
||||
value = base64.b64encode(secrets.token_bytes(32)).decode("ascii")
|
||||
LOCAL_VAULT_KEY_PATH.write_text(value + "\n", encoding="utf-8")
|
||||
@@ -828,7 +830,15 @@ def vault_exists():
|
||||
|
||||
|
||||
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():
|
||||
@@ -850,8 +860,8 @@ def write_public_auth(data):
|
||||
|
||||
|
||||
def project_locator_path():
|
||||
"""Return the repository-visible project locator beside private auth state."""
|
||||
return AUTH_DIR / PROJECT_LOCATOR_FILENAME
|
||||
"""Return the only repository-visible project data: the public locator."""
|
||||
return PROJECT_DIR / PROJECT_LOCATOR_FILENAME
|
||||
|
||||
|
||||
def validate_project_locator(data):
|
||||
@@ -1081,7 +1091,7 @@ def write_vault(passphrase, payload):
|
||||
key = _derive_vault_key(passphrase, salt, VAULT_KDF_ITERATIONS)
|
||||
plaintext = json.dumps(payload, sort_keys=True, separators=(",", ":")).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)
|
||||
VAULT_PATH.write_text(
|
||||
json.dumps(
|
||||
@@ -1140,7 +1150,7 @@ def derive_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)
|
||||
GATEWAY_RELAY_SECRET_PATH.write_text(
|
||||
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["schema"] = GATEWAY_RELAY_STATE_SCHEMA
|
||||
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)
|
||||
GATEWAY_RELAY_STATE_PATH.write_text(json.dumps(current, indent=2) + "\n", encoding="utf-8")
|
||||
chmod_best_effort(GATEWAY_RELAY_STATE_PATH, 0o600)
|
||||
@@ -1325,7 +1335,7 @@ def ensure_container_uuid():
|
||||
"container_uuid": value,
|
||||
"created_at": iso_now(),
|
||||
}
|
||||
AUTH_DIR.mkdir(exist_ok=True)
|
||||
AUTH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
chmod_best_effort(AUTH_DIR, 0o700)
|
||||
CONTAINER_IDENTITY_PATH.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
chmod_best_effort(CONTAINER_IDENTITY_PATH, 0o600)
|
||||
@@ -1429,7 +1439,8 @@ def gateway_status():
|
||||
"status": status,
|
||||
"message": message,
|
||||
**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"),
|
||||
"container_uuid": read_container_uuid(),
|
||||
"vault_path": str(VAULT_PATH),
|
||||
@@ -1610,7 +1621,7 @@ def clean_terminal_name(value, fallback="terminal"):
|
||||
|
||||
def default_gateway_session_name():
|
||||
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):
|
||||
@@ -2175,7 +2186,7 @@ def write_gateway_schedules_state(state):
|
||||
state["schema"] = GATEWAY_SCHEDULES_SCHEMA
|
||||
state["version"] = 1
|
||||
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)
|
||||
GATEWAY_SCHEDULES_PATH.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
chmod_best_effort(GATEWAY_SCHEDULES_PATH, 0o600)
|
||||
@@ -6567,7 +6578,7 @@ INDEX_SCHEMA_READY = {}
|
||||
|
||||
|
||||
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)
|
||||
handle = INDEX_LOCK_PATH.open("a+b")
|
||||
chmod_best_effort(INDEX_LOCK_PATH, 0o600)
|
||||
@@ -6599,7 +6610,7 @@ def ensure_sqlite_column(conn, table, column, declaration):
|
||||
|
||||
|
||||
def open_index_connection():
|
||||
AUTH_DIR.mkdir(exist_ok=True)
|
||||
AUTH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
chmod_best_effort(AUTH_DIR, 0o700)
|
||||
conn = sqlite3.connect(str(INDEX_PATH), timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
@@ -8526,18 +8537,19 @@ def _refresh_public_auth_state_locked():
|
||||
|
||||
|
||||
def ensure_local_files():
|
||||
AUTH_DIR.mkdir(exist_ok=True)
|
||||
AUTH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
chmod_best_effort(AUTH_DIR, 0o700)
|
||||
ensure_project_locator_ignore_policy()
|
||||
key_path = pathlib.Path(AUTH["ssh_key_path"] or DEFAULT_KEY_PATH).expanduser()
|
||||
public_key_path = pathlib.Path(str(key_path) + ".pub")
|
||||
if not key_path.exists():
|
||||
key_path.parent.mkdir(exist_ok=True)
|
||||
chmod_best_effort(key_path.parent, 0o700)
|
||||
subprocess.run(
|
||||
["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key_path)],
|
||||
check=True,
|
||||
)
|
||||
with credential_lock(key_path):
|
||||
if not key_path.exists():
|
||||
key_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
chmod_best_effort(key_path.parent, 0o700)
|
||||
subprocess.run(
|
||||
["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key_path)],
|
||||
check=True,
|
||||
)
|
||||
chmod_best_effort(key_path, 0o600)
|
||||
if public_key_path.exists():
|
||||
chmod_best_effort(public_key_path, 0o644)
|
||||
@@ -9223,11 +9235,12 @@ def cli_project_init(args):
|
||||
"status": "already_initialized",
|
||||
"message": (
|
||||
"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_slug": existing_slug,
|
||||
"auth_path": str(AUTH_PATH),
|
||||
"auth_path": str(active_auth_path()),
|
||||
"state_dir": str(AUTH_DIR),
|
||||
"project_locator": locator_result,
|
||||
}
|
||||
|
||||
@@ -9240,7 +9253,8 @@ def cli_project_init(args):
|
||||
),
|
||||
"project_id": locator["project_id"],
|
||||
"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),
|
||||
"next": {"attach_project": "nap project attach"},
|
||||
}
|
||||
@@ -9280,7 +9294,8 @@ def cli_project_init(args):
|
||||
"to start a local gateway."
|
||||
),
|
||||
"steps": steps,
|
||||
"auth_path": str(AUTH_PATH),
|
||||
"auth_path": str(active_auth_path()),
|
||||
"state_dir": str(AUTH_DIR),
|
||||
"project_locator": project_locator_status(),
|
||||
}
|
||||
|
||||
@@ -9289,7 +9304,8 @@ def cli_project_init(args):
|
||||
"status": "initialized",
|
||||
"message": "Folder initialized; existing project kept.",
|
||||
"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(),
|
||||
"steps": steps,
|
||||
}
|
||||
@@ -9515,7 +9531,10 @@ def cli_gateway_service_call(route, payload=None):
|
||||
# session endpoint existed.
|
||||
session = cli_gateway_service_json("GET", f"{base_url}/local-api/state")
|
||||
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:
|
||||
raise RuntimeError("running gateway belongs to a different project folder")
|
||||
csrf_token = session.get("csrf_token")
|
||||
@@ -14118,7 +14137,8 @@ def start_local_ui(args):
|
||||
"csrf_token": csrf_token,
|
||||
"agent_id": AUTH.get("agent_id"),
|
||||
"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"),
|
||||
"auth_configured": bool(TOKEN),
|
||||
"wrapper": {
|
||||
@@ -14211,7 +14231,8 @@ def start_local_ui(args):
|
||||
"csrf_token": csrf_token,
|
||||
"agent_id": AUTH.get("agent_id"),
|
||||
"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"),
|
||||
"auth_configured": bool(TOKEN),
|
||||
"wrapper": {
|
||||
@@ -14333,7 +14354,8 @@ def start_local_ui(args):
|
||||
if parsed_path.path == "/local-api/session":
|
||||
self.send_json(200, {
|
||||
"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(),
|
||||
})
|
||||
return
|
||||
@@ -14358,7 +14380,7 @@ def start_local_ui(args):
|
||||
<html><head><meta charset="utf-8"><title>Napseer local manager</title>
|
||||
<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>
|
||||
<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)}">
|
||||
<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>
|
||||
@@ -14432,7 +14454,7 @@ def start_local_ui(args):
|
||||
</style></head>
|
||||
<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>
|
||||
<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>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>
|
||||
@@ -16971,6 +16993,8 @@ def safe_auth_refresh_error():
|
||||
|
||||
|
||||
def safe_exception_text(exc):
|
||||
if isinstance(exc, CredentialError):
|
||||
return str(exc) # CredentialError messages are fixed, secret-free diagnostics.
|
||||
if isinstance(exc, SafeToolError):
|
||||
return exc.safe_message
|
||||
return (
|
||||
@@ -17919,6 +17943,8 @@ def call_tool_impl(name, args):
|
||||
"Unknown or unavailable MCP tool. Use `nap_apropos` to discover the canonical surface.",
|
||||
)
|
||||
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():
|
||||
profile = tool_profile(canonical_name)
|
||||
raise SafeToolError(
|
||||
@@ -18250,7 +18276,8 @@ def handle(message):
|
||||
"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.",
|
||||
"napseer": {
|
||||
"auth_path": str(AUTH_PATH),
|
||||
"auth_path": str(active_auth_path()),
|
||||
"state_dir": str(AUTH_DIR),
|
||||
"base_url": BASE_URL,
|
||||
"has_token": bool(TOKEN),
|
||||
"project_id": DEFAULT_PROJECT_ID,
|
||||
@@ -18390,6 +18417,9 @@ class McpRequestRuntime:
|
||||
|
||||
def cli_main(argv):
|
||||
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":
|
||||
args = argv[2:]
|
||||
validate_cli_options(
|
||||
@@ -18452,7 +18482,7 @@ def cli_main(argv):
|
||||
nap auth logout [--project]
|
||||
nap auth api-key --env VARIABLE_NAME
|
||||
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.
|
||||
|
||||
`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
|
||||
if subcommand in {"use-user","migrate"}:
|
||||
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
|
||||
if subcommand in {"login", "login-local", "operator-login"}:
|
||||
validate_cli_options(
|
||||
|
||||
+2
-2
@@ -3,8 +3,8 @@ import pytest
|
||||
|
||||
|
||||
@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.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"):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
@@ -48,13 +48,15 @@ def test_stdin_bootstrap_installs_verified_runtime_and_preserves_credentials(tmp
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Provider)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
user_data = tmp_path / "user-data"
|
||||
user_data.mkdir(exist_ok=True)
|
||||
auth = user_data / "auth.json"
|
||||
auth.write_text('{"refresh_token":"synthetic-preserved"}')
|
||||
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_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:
|
||||
result = subprocess.run([sys.executable, "-"], input=(source / "nap_install.py").read_text(),
|
||||
cwd=tmp_path, env=env, capture_output=True, text=True, timeout=30)
|
||||
|
||||
@@ -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()
|
||||
serialized = json.dumps(result)
|
||||
|
||||
|
||||
@@ -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):
|
||||
mod = load_module()
|
||||
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
|
||||
monkeypatch.setattr(mod, "refresh_public_auth_state", lambda: None)
|
||||
calls = []
|
||||
|
||||
@@ -26,6 +26,7 @@ def load_module():
|
||||
def configure_state(mod, root):
|
||||
state_dir = pathlib.Path(root) / ".napseer"
|
||||
mod.AUTH_DIR = state_dir
|
||||
mod.PROJECT_DIR = state_dir
|
||||
mod.AUTH_PATH = state_dir / "auth.json"
|
||||
mod.BASE_URL = "https://api.example.test"
|
||||
mod.TOKEN = None
|
||||
|
||||
@@ -29,11 +29,11 @@ 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
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(test_dir / filename)],
|
||||
cwd=test_dir.parent,
|
||||
cwd=tmp_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=90,
|
||||
|
||||
@@ -13,7 +13,7 @@ import pytest
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "resources/scripts/napseer_mcp_server.py"
|
||||
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
|
||||
@@ -204,7 +204,8 @@ def test_legacy_migration_preserves_authority_and_general_selection_is_explicit(
|
||||
store=seed(tmp_path,url)
|
||||
legacy={"base_url":url,"token":"legacy-project","account_mode":"operator_project","project_id":"test-project"}
|
||||
atomic_json(store.legacy,legacy)
|
||||
assert store.selected()[1]=="legacy_project"
|
||||
with pytest.raises(CredentialError, match="needs migration"):
|
||||
store.selected()
|
||||
store.migrate()
|
||||
assert not store.legacy.exists()
|
||||
assert store.read()["token"]=="legacy-project"
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user