diff --git a/Dockerfile b/Dockerfile index 6b0180c..0b988ab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,11 +10,13 @@ RUN dnf -y install ca-certificates git openssh-clients python3 tmux util-linux \ && dnf clean all COPY resources/scripts/napseer_mcp_server.py /opt/napseer/napseer_mcp_server.py +COPY resources/scripts/napseer_credentials.py /opt/napseer/napseer_credentials.py COPY resources/scripts/napseer_spake2.py /opt/napseer/napseer_spake2.py COPY resources/scripts/terminal/ /opt/napseer/terminal/ RUN python3 -m py_compile \ /opt/napseer/napseer_mcp_server.py \ + /opt/napseer/napseer_credentials.py \ /opt/napseer/napseer_spake2.py \ /opt/napseer/terminal/__init__.py \ /opt/napseer/terminal/protocol.py \ @@ -27,6 +29,7 @@ ENV NAPSEER_GATEWAY_PORT=0 ENV NAPSEER_GATEWAY_PTY_TERMINAL=1 ENV NAPSEER_SERVICE_ACTIVATION_TIMEOUT_SECONDS=900 -VOLUME ["/workspace/.napseer"] +ENV NAPSEER_USER_DATA_DIR=/var/lib/napseer +VOLUME ["/var/lib/napseer"] CMD ["python3", "/opt/napseer/napseer_mcp_server.py", "gateway", "service", "run"] diff --git a/README.md b/README.md index 0b8bef9..1d8a408 100644 --- a/README.md +++ b/README.md @@ -20,3 +20,22 @@ Current state: image. It performs no live Napseer API download during the build. The backend remains the protocol and compatibility source of truth through `/v1/gateway-protocol`. + +Private gateway state is stored outside the checkout using the same +`WorkspacePaths` contract as the CLI. The image sets +`NAPSEER_USER_DATA_DIR=/var/lib/napseer`; persist that directory in a dedicated +volume and mount source code separately at `/workspace`. Vaults, worker grants, +logs, indexes, and process state belong to a worktree; repository signing keys +are shared across related Git worktrees. + +To migrate an existing gateway, stop its old process and retain the old volume. +Mount that volume at `/workspace/.napseer` and the new persistent volume at +`/var/lib/napseer`, then run the new image with +`python3 /opt/napseer/napseer_mcp_server.py auth migrate` as its command. +Start the service after migration succeeds. Conflicting state is preserved for +explicit reconciliation. Do not run old and new gateway versions against the +same credentials during migration. + +`resources/scripts/napseer_credentials.py` vendors the CLI's private-state +contract so the gateway image requires no runtime download. Keep its path and +migration behavior aligned with the CLI when changing storage layout. diff --git a/resources/scripts/napseer_credentials.py b/resources/scripts/napseer_credentials.py new file mode 100644 index 0000000..19dbb2d --- /dev/null +++ b/resources/scripts/napseer_credentials.py @@ -0,0 +1,433 @@ +"""User-owned credentials, separate from repository identity and gateway state. + +No network, enrollment, or credential output belongs in this module. +""" +from contextlib import contextmanager +import hashlib +import json +import os +from pathlib import Path +import stat +import subprocess +import sys +import tempfile +import threading +import time + + +class CredentialError(RuntimeError): + pass + + +CREDENTIAL_KEYS = { + "base_url", "account_id", "token", "access_token", "refresh_token", + "token_expires_at", "refresh_expires_at", "account_mode", "oauth_client_id", + "oauth_scope", "logged_out", "credential_kind", +} +SELECTION_KEYS = { + "project_id", "project_slug", "project_name", "project_encryption_state", + "project_signing_key_fingerprint", "credential_profile", +} +_registry_lock = threading.Lock() +_thread_locks = {} +_held = threading.local() + + +def user_data_dir(): + if os.environ.get("NAPSEER_USER_DATA_DIR"): + path = Path(os.environ["NAPSEER_USER_DATA_DIR"]).expanduser() + if not path.is_absolute(): + raise CredentialError("NAPSEER_USER_DATA_DIR must be an absolute path outside the checkout.") + return path.resolve() + if sys.platform == "win32": + return Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) / "napseer" + if sys.platform == "darwin": + return Path.home() / "Library" / "Application Support" / "napseer" + xdg = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) + return (xdg if xdg.is_absolute() else Path.home() / ".local" / "share") / "napseer" + + +class WorkspacePaths: + """Pure path resolution: Git identity survives worktree moves, not copies. + + The public locator stays in Git. Repository credentials/signing keys are + shared by related worktrees; daemon/vault/cache state belongs to one worktree. + No credentials are read and no directory is created during path discovery. + """ + def __init__(self, root=None): + start = Path(root or os.environ.get("NAPSEER_PROJECT_ROOT") or Path.cwd()).expanduser().resolve() + self.root = start + git_dir = common = None + environment = {k:v for k,v in os.environ.items() if k not in + {"GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE"}} + try: + result = subprocess.run(["git", "-C", str(start), "rev-parse", "--path-format=absolute", + "--show-toplevel", "--git-dir", "--git-common-dir"], + capture_output=True, text=True, timeout=3, env=environment) + if result.returncode == 0: + top, git_dir, common = result.stdout.strip().splitlines() + self.root = Path(top).resolve() + except (OSError, ValueError, subprocess.TimeoutExpired): + pass + # A nested project's explicit locator is a separate namespace within Git. + for candidate in (start, *start.parents): + if (candidate / ".napseer/project.json").is_file(): + self.root = candidate + break + if git_dir and candidate == self.root: + break + suffix = str(self.root.relative_to(Path(top).resolve())) if git_dir else "" + repo_identity = str(Path(common).resolve()) + ":" + suffix if common else str(self.root) + worktree_identity = str(Path(git_dir).resolve()) + ":" + suffix if git_dir else str(self.root) + self.repository_id = hashlib.sha256(repo_identity.encode()).hexdigest() + self.workspace_id = hashlib.sha256(worktree_identity.encode()).hexdigest() + self.data = user_data_dir().expanduser().resolve() + if self.data.is_relative_to(self.root) and self.root != Path.home().resolve(): + raise CredentialError("Napseer user data must be outside the project checkout.") + self.legacy = self.root / ".napseer" + self.locator = self.legacy / "project.json" + self.repository = self.data / "repositories" / self.repository_id + self.state = self.data / "workspaces" / self.workspace_id + self.signing_key = self.repository / "id_ed25519" + self.project_credentials = self.data / "credentials/projects" / (self.repository_id + ".json") + old_id = hashlib.sha256(str(self.root).encode()).hexdigest() + self.old_credentials = self.data / "credentials/projects" / (old_id + ".json") + + def migration_sources(self): + if self.legacy.is_symlink(): + raise CredentialError("Legacy Napseer state must not be a symbolic link.") + sources = [] + if self.legacy.exists(): + sources = [p for p in self.legacy.iterdir() if p.name not in {"project.json", ".gitignore"}] + if self.old_credentials != self.project_credentials and self.old_credentials.exists(): + sources.append(self.old_credentials) + return sources + + def require_migrated(self): + if self.migration_sources(): + raise CredentialError("Repository-local Napseer state needs migration. Stop older MCP clients and local gateways, then run `nap auth migrate`.") + + def migrate(self, *, gateway=False): + """Copy/verify/publish before removing originals; never overwrite conflicts. + + Older processes must be stopped before this explicit operation. Existing + daemon PIDs are checked. A partial copy can be retried safely; all source + bytes remain until every destination has been verified. + """ + with credential_lock(self.data / "migration"): + sources = self.migration_sources() + if not sources: + return {"status":"already_migrated", "state_dir":str(self.state)} + for name in ("gateway.json", "ui.json"): + state = read_json(self.legacy / name) + pid = state.get("pid") + if isinstance(pid, int) and pid > 0: + try: + os.kill(pid, 0) + except ProcessLookupError: + continue + except OSError: + pass + raise CredentialError("Stop the running local gateway/UI before `nap auth migrate`.") + entries = [] + for source in sources: + if source.is_symlink() or not source.is_file(): + raise CredentialError("Migration requires regular files; original state was preserved.") + destination = self.state / source.name + if source.name in {"id_ed25519", "id_ed25519.pub"}: + destination = self.repository / source.name + if source.name == "auth.json" or source == self.old_credentials: + destination = (self.state / "auth.json") if gateway and source.name == "auth.json" else self.project_credentials + original = file_fingerprint(source) + copied = None + if source.name in {"auth.json", "gateway-auth.json", "state.json"} or source == self.old_credentials: + data = read_json(source) + for field, old, new in ( + ("ssh_key_path", self.legacy / "id_ed25519", self.signing_key), + ("vault_path", self.legacy / "vault.json", self.state / "vault.json"), + ): + value = data.get(field) + if isinstance(value, str): + candidate = Path(value).expanduser() + candidate = candidate if candidate.is_absolute() else self.root / candidate + if candidate.resolve() == old.resolve(): data[field] = str(new) + copied = (json.dumps(data, indent=2) + "\n").encode() + if destination.is_symlink(): + raise CredentialError("Migration destination must not be a symbolic link.") + expected = (len(copied), hashlib.sha256(copied).hexdigest()) if copied is not None else original + if destination.exists() and file_fingerprint(destination) != expected: + raise CredentialError("Migration conflicts with existing private state; nothing was overwritten. Reconcile the two states before retrying.") + if any(dst == destination and digest != expected for _,dst,_,_,digest in entries): + raise CredentialError("Legacy credentials conflict; original state was preserved.") + entries.append((source, destination, original, copied, expected)) + # Publish credentials last, after their keys and other state exist. + entries.sort(key=lambda e: e[1] in {self.project_credentials, self.state / "auth.json"}) + for source, destination, original, copied, expected in entries: + private_directory(destination.parent, self.data) + if not destination.exists(): + fd, temporary = tempfile.mkstemp(prefix=".migrate-", dir=destination.parent) + try: + with os.fdopen(fd, "wb") as stream: + os.fchmod(stream.fileno(), 0o600) + if copied is not None: + stream.write(copied) + else: + with source.open("rb") as original_stream: + while chunk := original_stream.read(1024 * 1024): + stream.write(chunk) + stream.flush(); os.fsync(stream.fileno()) + # Publish without replacing another login or migration's file. + try: + os.link(temporary, destination) + except FileExistsError: + if file_fingerprint(destination) != expected: + raise CredentialError("Migration destination changed; original state was preserved.") from None + finally: + if os.path.exists(temporary): os.unlink(temporary) + if file_fingerprint(destination) != expected or file_fingerprint(source) != original: + raise CredentialError("State changed during migration; original files were preserved. Stop older clients and retry.") + for source, destination, original, copied, expected in entries: + if file_fingerprint(source) != original or file_fingerprint(destination) != expected: + raise CredentialError("Migration verification failed; remaining originals were preserved.") + # Make published directory entries durable before removing originals. + # Windows does not support opening directories for fsync this way. + if os.name != "nt": + parents = set() + for _, destination, _, _, _ in entries: + for parent in (destination.parent, *destination.parent.parents): + parents.add(parent) + if parent == self.data: break + for parent in sorted(parents, key=lambda p: len(p.parts), reverse=True): + fd = os.open(parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) + try: os.fsync(fd) + finally: os.close(fd) + for source, _, _, _, _ in entries: + source.unlink() + return {"status":"migrated", "state_dir":str(self.state), "files":len(entries)} + + +def private_directory(path, boundary): + path, boundary = Path(path), Path(boundary) + if not path.is_relative_to(boundary): + raise CredentialError("Private state directory is outside its user data boundary.") + for parent in (path, *path.parents): + if parent.is_symlink(): + raise CredentialError("Private Napseer state must not use symlink directories.") + if parent == boundary: break + path.mkdir(mode=0o700, parents=True, exist_ok=True) + for parent in (path, *path.parents): + parent.chmod(0o700) + if parent == boundary: break + + +def file_fingerprint(path): + digest = hashlib.sha256() + fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) + with os.fdopen(fd, "rb") as stream: + if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode): + raise CredentialError("Migration requires regular files.") + size = 0 + while chunk := stream.read(1024 * 1024): + digest.update(chunk); size += len(chunk) + return size, digest.hexdigest() + + +def read_json(path): + try: + fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) + except FileNotFoundError: + return {} + except OSError: + raise CredentialError("Credential state cannot be opened safely.") from None + try: + if not stat.S_ISREG(os.fstat(fd).st_mode): + raise CredentialError("Credential state must be a regular file.") + with os.fdopen(fd, "r", encoding="utf-8") as stream: + fd = None + raw = stream.read(65537) + if len(raw) > 65536: + raise CredentialError("Credential state exceeds the size limit.") + result = json.loads(raw) + if not isinstance(result, dict): + raise ValueError() + return result + except (ValueError, UnicodeError, OSError): + raise CredentialError("Credential state is unreadable; run nap auth login to repair it.") from None + finally: + if fd is not None: + os.close(fd) + + +def atomic_json(path, data): + path = Path(path) + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + if path.is_symlink(): + raise CredentialError("Credential state must not be a symbolic link.") + fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as stream: + os.fchmod(stream.fileno(), 0o600) + json.dump(data, stream, indent=2) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +@contextmanager +def credential_lock(path, timeout=30): + """Bounded process lock; nested calls in the same thread reuse the lock.""" + path = Path(path).absolute() + with _registry_lock: + thread_lock = _thread_locks.setdefault(str(path), threading.RLock()) + if not thread_lock.acquire(timeout=timeout): + raise CredentialError("Credential update is busy; retry after the other login or refresh finishes.") + try: + held = getattr(_held, "paths", {}) + if str(path) in held: + yield + return + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + try: + fd = os.open(str(path) + ".lock", os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600) + except OSError: + raise CredentialError("Credential lock cannot be opened safely.") from None + try: + if not stat.S_ISREG(os.fstat(fd).st_mode): + raise CredentialError("Credential lock must be a regular file.") + deadline = time.monotonic() + timeout + while True: + try: + if os.name == "nt": + import msvcrt + os.lseek(fd, 0, os.SEEK_SET) + msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) + else: + import fcntl + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except (BlockingIOError, OSError): + if time.monotonic() >= deadline: + raise CredentialError("Credential update is busy; retry after the other login or refresh finishes.") from None + time.sleep(0.025) + held[str(path)] = fd + _held.paths = held + try: + yield + finally: + held.pop(str(path), None) + finally: + os.close(fd) + finally: + thread_lock.release() + + +class CredentialStore: + def __init__(self, state_dir, legacy_path=None): + self.workspace = WorkspacePaths(Path(state_dir).parent) + self.state_dir = self.workspace.state + self.legacy = Path(legacy_path or self.workspace.legacy / "auth.json").expanduser().absolute() + self.user = user_data_dir() / "credentials" / "default.json" + self.project = self.workspace.project_credentials + self.selection = self.state_dir / "state.json" + + def selected(self): + bound = getattr(_held,"bindings",{}).get(str(self.state_dir.absolute())) + if bound: return bound + explicit = os.environ.get("NAPSEER_AUTH_FILE") + if explicit: + path = Path(explicit).expanduser().absolute() + if path.is_symlink(): + raise CredentialError("Explicit credentials must not be a symbolic link.") + if path.resolve().is_relative_to(self.workspace.root) and self.workspace.root != Path.home().resolve(): + raise CredentialError("NAPSEER_AUTH_FILE must be outside the checkout; migrate private state first.") + return path, "explicit" + if self.legacy != self.workspace.legacy / "auth.json": + if self.legacy.resolve().is_relative_to(self.workspace.root) and self.workspace.root != Path.home().resolve(): + raise CredentialError("Explicit credentials must be outside the checkout; migrate private state first.") + return self.legacy, "explicit" + self.workspace.require_migrated() + if read_json(self.selection).get("credential_profile") == "user": + return self.user, "user" + if self.project.exists() or self.project.is_symlink(): + return self.project, "project" + return self.user, "user" + + @contextmanager + def transaction(self): + """Pin one credential source through read/refresh/write despite new overrides.""" + identity = str(self.state_dir.absolute()) + chosen = self.selected() + with credential_lock(chosen[0]): + bindings = getattr(_held,"bindings",{}) + previous = bindings.get(identity) + bindings[identity] = chosen; _held.bindings = bindings + try: yield + finally: + if previous: bindings[identity] = previous + else: bindings.pop(identity,None) + + def use_user(self): + current = read_json(self.user) + if not current.get("token") or current.get("logged_out"): + raise CredentialError("Run nap auth login before selecting the general user login.") + with credential_lock(self.selection): + selection = read_json(self.selection) + selection["credential_profile"] = "user" + atomic_json(self.selection, selection) + return self.user + + def migrate(self): + self.workspace.migrate() + return self.project + + def read(self): + path, source = self.selected() + credentials = read_json(path) + selection = {k: v for k, v in read_json(self.selection).items() if k in SELECTION_KEYS} + # Project-bound grants retain their issued project. General grants only + # consume repository selection; selection cannot confer authority. + return {**selection, **credentials} + + def write(self, data): + path, source = self.selected() + with credential_lock(path): + if source == "explicit": + atomic_json(path, data) + return + credentials = {k: v for k, v in data.items() if k in CREDENTIAL_KEYS} + if source == "project": + # Preserve project-bound worker/recovery/key metadata during + # refresh after legacy migration. Profile selection stays local + # to the worktree and must not change sibling worktrees. + credentials = {k: v for k, v in data.items() if k != "credential_profile"} + current = read_json(path) + # Reading project metadata must not churn or overwrite shared + # credentials, including after another process logged out. + if credentials != current: + atomic_json(path, credentials) + selection = {**read_json(self.selection), **{k: v for k, v in data.items() if k in SELECTION_KEYS}} + if selection != read_json(self.selection): + atomic_json(self.selection, selection) + + def login(self, credentials, project=False): + target = self.project if project else self.user + with credential_lock(target): + atomic_json(target, credentials) + if project: + with credential_lock(self.selection): + selection = read_json(self.selection); selection["credential_profile"] = "project" + atomic_json(self.selection, selection) + return target + + def logout(self, project=False): + target = self.selected()[0] if project else self.user + with credential_lock(target): + previous = read_json(target) + # Keep a tombstone: removing an override would silently restore the + # more privileged user session on the next request. + atomic_json(target, {"base_url": previous.get("base_url"), "logged_out": True}) + return target diff --git a/resources/scripts/napseer_mcp_server.py b/resources/scripts/napseer_mcp_server.py index be1a234..1eb6cbf 100644 --- a/resources/scripts/napseer_mcp_server.py +++ b/resources/scripts/napseer_mcp_server.py @@ -49,6 +49,8 @@ import webbrowser from datetime import datetime, timedelta, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from napseer_credentials import WorkspacePaths, credential_lock + from napseer_spake2 import ( relay_aad, relay_context_hash, @@ -71,31 +73,17 @@ class RelaySocketClosed(RuntimeError): def local_state_dir(): - preferred = pathlib.Path.cwd() / ".napseer" - legacy = pathlib.Path.cwd() / "napseer" - if preferred.exists() or not legacy.exists(): - return preferred - return legacy + return WorkspacePaths().state def state_dir_status_payload(): - preferred = pathlib.Path.cwd() / ".napseer" - legacy = pathlib.Path.cwd() / "napseer" - active = AUTH_DIR - if preferred.exists() and legacy.exists(): - message = "Both .napseer and legacy napseer directories exist; .napseer is active." - elif active == legacy: - message = "Legacy napseer directory is active because .napseer does not exist." - else: - message = ".napseer directory is active." return { - "active_state_dir": str(active), - "preferred_state_dir": str(preferred), - "legacy_state_dir": str(legacy), - "legacy_state_dir_active": active == legacy, - "preferred_state_dir_exists": preferred.exists(), - "legacy_state_dir_exists": legacy.exists(), - "state_dir_message": message, + "active_state_dir": str(AUTH_DIR), + "state_dir": str(AUTH_DIR), + "state_dir_exists": AUTH_DIR.exists(), + "project_root": str(WORKSPACE.root), + "state_dir_message": "Private gateway state is stored in user data.", + "migration_required": bool(WORKSPACE.migration_sources()), } @@ -206,7 +194,11 @@ def send_telemetry_event_async(event, component, outcome="success", **fields): ).start() -AUTH_DIR = local_state_dir() +WORKSPACE = WorkspacePaths() +# The pre-dot-directory gateway layout is migration input only. +if not WORKSPACE.legacy.exists() and any((WORKSPACE.root / "napseer" / name).is_file() for name in ("auth.json", "vault.json")): + WORKSPACE.legacy = WORKSPACE.root / "napseer" +AUTH_DIR = WORKSPACE.state AUTH_PATH = AUTH_DIR / "auth.json" VAULT_PATH = AUTH_DIR / "vault.json" GATEWAY_RELAY_SECRET_PATH = AUTH_DIR / "gateway-relay-secret.json" @@ -216,10 +208,10 @@ 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",) +SCRIPT_DEPENDENCY_NAMES = ("napseer_spake2.py", "napseer_credentials.py") CONTRACT_VERSION = "napseer.mcp.contract.v1" TELEMETRY_DISABLED_VALUES = {"0", "false", "no", "off", "disabled"} TELEMETRY_ALLOWED_FIELDS = { @@ -687,7 +679,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( @@ -746,7 +738,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( @@ -802,7 +794,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) @@ -931,7 +923,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) @@ -1164,7 +1156,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): @@ -1727,7 +1719,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) @@ -5135,7 +5127,7 @@ def request_project_write(method, path, payload, project_id, purpose, scope_type def index_file_lock(): - 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) @@ -5153,7 +5145,7 @@ def release_index_file_lock(handle): def index_connect(): - 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 @@ -6032,20 +6024,21 @@ def save_auth(updates): def ensure_local_files(): - AUTH_DIR.mkdir(exist_ok=True) + AUTH_DIR.mkdir(parents=True, exist_ok=True) chmod_best_effort(AUTH_DIR, 0o700) gitignore = AUTH_DIR / ".gitignore" if not gitignore.exists(): gitignore.write_text("*\n!.gitignore\n", encoding="utf-8") 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) @@ -9816,6 +9809,11 @@ def handle(message): def cli_main(argv): command = argv[1] if len(argv) > 1 else "" + if argv[1:] == ["auth", "migrate"]: + print(json.dumps(WORKSPACE.migrate(gateway=True), indent=2)) + return + if command not in {"help", "-h", "--help"}: + WORKSPACE.require_migrated() if command in {"configure", "config"}: print(json.dumps(cli_configure_status(), indent=2)) return diff --git a/scripts/test_gateway_state_paths.py b/scripts/test_gateway_state_paths.py new file mode 100644 index 0000000..15c3ae1 --- /dev/null +++ b/scripts/test_gateway_state_paths.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Exercise gateway imports and its migration command using disposable state.""" +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile + +SCRIPT = Path(__file__).resolve().parents[1]/'resources/scripts/napseer_mcp_server.py' + +with tempfile.TemporaryDirectory(prefix='gateway-private-state-') as directory: + root=Path(directory)/'workspace'; root.mkdir() + data=Path(directory)/'data' + env=dict(os.environ,NAPSEER_USER_DATA_DIR=str(data),NAPSEER_PROJECT_ROOT=str(root),NAPSEER_TELEMETRY='0') + for name in ('NAPSEER_TOKEN','NAPSEER_REFRESH_TOKEN','NAPSEER_API_KEY','NAPSEER_AUTH_FILE'): + env.pop(name,None) + code="import sys; sys.path.insert(0,sys.argv[1]); import napseer_mcp_server as m; import json; print(json.dumps({'state':str(m.AUTH_DIR),'auth':str(m.AUTH_PATH),'key':str(m.DEFAULT_KEY_PATH)}))" + def paths(): + result=subprocess.run([sys.executable,'-c',code,str(SCRIPT.parent)],cwd=root,env=env,capture_output=True,text=True,timeout=10) + assert result.returncode==0,'gateway must load the private-state dependency' + return {k:Path(v) for k,v in json.loads(result.stdout).items()} + resolved=paths() + assert all(path.is_relative_to(data) and not path.is_relative_to(root) for path in resolved.values()) + legacy=root/'.napseer';legacy.mkdir() + (legacy/'auth.json').write_text(json.dumps({'token':'synthetic-worker-token','worker_id':'test-worker'})) + (legacy/'id_ed25519').write_bytes(b'synthetic-signing-key') + result=subprocess.run([sys.executable,str(SCRIPT),'auth','migrate'],cwd=root,env=env,capture_output=True,text=True,timeout=10) + assert result.returncode==0,'gateway migration must complete without enrollment' + assert json.loads(result.stdout)['status']=='migrated' + assert 'synthetic-worker-token' not in result.stdout+result.stderr + assert json.loads(resolved['auth'].read_text())['worker_id']=='test-worker' + assert resolved['key'].read_bytes()==b'synthetic-signing-key' + assert not list(legacy.iterdir()) + assert paths()==resolved + +print('ok: gateway private state and migration stay outside the checkout')