diff --git a/README.md b/README.md index 411e41d..839c635 100644 --- a/README.md +++ b/README.md @@ -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//` holds the shared signing key; `workspaces//` 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 diff --git a/resources/scripts/nap_install.py b/resources/scripts/nap_install.py index 5b66e19..67e9905 100644 --- a/resources/scripts/nap_install.py +++ b/resources/scripts/nap_install.py @@ -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({ diff --git a/resources/scripts/napseer_credentials.py b/resources/scripts/napseer_credentials.py index 4c67e83..19dbb2d 100644 --- a/resources/scripts/napseer_credentials.py +++ b/resources/scripts/napseer_credentials.py @@ -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. diff --git a/resources/scripts/napseer_mcp_server.py b/resources/scripts/napseer_mcp_server.py index 6031f31..7c51f0e 100755 --- a/resources/scripts/napseer_mcp_server.py +++ b/resources/scripts/napseer_mcp_server.py @@ -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): Napseer local manager -

Create this Napseer project

This localhost UI is running before a project is configured for {html.escape(str(AUTH_PATH))}.

+

Create this Napseer project

This localhost UI is running before a project is configured for {html.escape(str(active_auth_path()))}.

@@ -14432,7 +14454,7 @@ def start_local_ui(args):
NNapseer local manager
-

Project memory, local authority.

This UI runs on 127.0.0.1, reads {html.escape(str(AUTH_PATH))}, and publishes executable schedules with project auth.

+

Project memory, local authority.

This UI runs on 127.0.0.1, reads {html.escape(str(active_auth_path()))}, and publishes executable schedules with project auth.

Project{html.escape(project_id)}
Agent{html.escape(str(AUTH.get('agent_id') or 'unknown'))}
Token expires{html.escape(str(TOKEN_EXPIRES_AT or 'unknown'))}
@@ -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( diff --git a/tests/conftest.py b/tests/conftest.py index 26694cf..c7af331 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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) diff --git a/tests/test_bootstrap_entrypoint.py b/tests/test_bootstrap_entrypoint.py index ddeaf62..53477b3 100644 --- a/tests/test_bootstrap_entrypoint.py +++ b/tests/test_bootstrap_entrypoint.py @@ -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) diff --git a/tests/test_cli_command_system.py b/tests/test_cli_command_system.py index 2d2550a..1f0de8e 100644 --- a/tests/test_cli_command_system.py +++ b/tests/test_cli_command_system.py @@ -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) diff --git a/tests/test_mcp_worker_lifecycle.py b/tests/test_mcp_worker_lifecycle.py index 9395bc5..15bce51 100644 --- a/tests/test_mcp_worker_lifecycle.py +++ b/tests/test_mcp_worker_lifecycle.py @@ -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 = [] diff --git a/tests/test_project_locator.py b/tests/test_project_locator.py index 9b3e97a..8e9850d 100644 --- a/tests/test_project_locator.py +++ b/tests/test_project_locator.py @@ -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 diff --git a/tests/test_standalone_smokes.py b/tests/test_standalone_smokes.py index 18bb669..ee17742 100644 --- a/tests/test_standalone_smokes.py +++ b/tests/test_standalone_smokes.py @@ -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, diff --git a/tests/test_user_credentials.py b/tests/test_user_credentials.py index 0a44231..08d8fc4 100644 --- a/tests/test_user_credentials.py +++ b/tests/test_user_credentials.py @@ -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" diff --git a/tests/test_worktree_state.py b/tests/test_worktree_state.py new file mode 100644 index 0000000..009df46 --- /dev/null +++ b/tests/test_worktree_state.py @@ -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)