Persist gateway secrets and worktree state in user data

This commit is contained in:
HumbleTurtle
2026-09-21 13:19:27 -03:00
parent 19b047e7f8
commit 76f5b18b8a
5 changed files with 531 additions and 41 deletions
+4 -1
View File
@@ -10,11 +10,13 @@ RUN dnf -y install ca-certificates git openssh-clients python3 tmux util-linux \
&& dnf clean all && dnf clean all
COPY resources/scripts/napseer_mcp_server.py /opt/napseer/napseer_mcp_server.py 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/napseer_spake2.py /opt/napseer/napseer_spake2.py
COPY resources/scripts/terminal/ /opt/napseer/terminal/ COPY resources/scripts/terminal/ /opt/napseer/terminal/
RUN python3 -m py_compile \ RUN python3 -m py_compile \
/opt/napseer/napseer_mcp_server.py \ /opt/napseer/napseer_mcp_server.py \
/opt/napseer/napseer_credentials.py \
/opt/napseer/napseer_spake2.py \ /opt/napseer/napseer_spake2.py \
/opt/napseer/terminal/__init__.py \ /opt/napseer/terminal/__init__.py \
/opt/napseer/terminal/protocol.py \ /opt/napseer/terminal/protocol.py \
@@ -27,6 +29,7 @@ ENV NAPSEER_GATEWAY_PORT=0
ENV NAPSEER_GATEWAY_PTY_TERMINAL=1 ENV NAPSEER_GATEWAY_PTY_TERMINAL=1
ENV NAPSEER_SERVICE_ACTIVATION_TIMEOUT_SECONDS=900 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"] CMD ["python3", "/opt/napseer/napseer_mcp_server.py", "gateway", "service", "run"]
+19
View File
@@ -20,3 +20,22 @@ Current state:
image. It performs no live Napseer API download during the build. 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`. 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.
+433
View File
@@ -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
+32 -34
View File
@@ -49,6 +49,8 @@ import webbrowser
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from napseer_credentials import WorkspacePaths, credential_lock
from napseer_spake2 import ( from napseer_spake2 import (
relay_aad, relay_aad,
relay_context_hash, relay_context_hash,
@@ -71,31 +73,17 @@ class RelaySocketClosed(RuntimeError):
def local_state_dir(): def local_state_dir():
preferred = pathlib.Path.cwd() / ".napseer" return WorkspacePaths().state
legacy = pathlib.Path.cwd() / "napseer"
if preferred.exists() or not legacy.exists():
return preferred
return legacy
def state_dir_status_payload(): 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 { return {
"active_state_dir": str(active), "active_state_dir": str(AUTH_DIR),
"preferred_state_dir": str(preferred), "state_dir": str(AUTH_DIR),
"legacy_state_dir": str(legacy), "state_dir_exists": AUTH_DIR.exists(),
"legacy_state_dir_active": active == legacy, "project_root": str(WORKSPACE.root),
"preferred_state_dir_exists": preferred.exists(), "state_dir_message": "Private gateway state is stored in user data.",
"legacy_state_dir_exists": legacy.exists(), "migration_required": bool(WORKSPACE.migration_sources()),
"state_dir_message": message,
} }
@@ -206,7 +194,11 @@ def send_telemetry_event_async(event, component, outcome="success", **fields):
).start() ).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" AUTH_PATH = AUTH_DIR / "auth.json"
VAULT_PATH = AUTH_DIR / "vault.json" VAULT_PATH = AUTH_DIR / "vault.json"
GATEWAY_RELAY_SECRET_PATH = AUTH_DIR / "gateway-relay-secret.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" TELEMETRY_PATH = AUTH_DIR / "telemetry.json"
INDEX_PATH = AUTH_DIR / "index.sqlite" INDEX_PATH = AUTH_DIR / "index.sqlite"
INDEX_LOCK_PATH = AUTH_DIR / "index.lock" INDEX_LOCK_PATH = AUTH_DIR / "index.lock"
DEFAULT_KEY_PATH = AUTH_DIR / "id_ed25519" DEFAULT_KEY_PATH = WORKSPACE.signing_key
SCRIPT_PATH = pathlib.Path(__file__).resolve() SCRIPT_PATH = pathlib.Path(__file__).resolve()
SCRIPT_NAME = SCRIPT_PATH.name SCRIPT_NAME = SCRIPT_PATH.name
SCRIPT_DEPENDENCY_NAMES = ("napseer_spake2.py",) SCRIPT_DEPENDENCY_NAMES = ("napseer_spake2.py", "napseer_credentials.py")
CONTRACT_VERSION = "napseer.mcp.contract.v1" CONTRACT_VERSION = "napseer.mcp.contract.v1"
TELEMETRY_DISABLED_VALUES = {"0", "false", "no", "off", "disabled"} TELEMETRY_DISABLED_VALUES = {"0", "false", "no", "off", "disabled"}
TELEMETRY_ALLOWED_FIELDS = { TELEMETRY_ALLOWED_FIELDS = {
@@ -687,7 +679,7 @@ def write_vault(passphrase, payload):
key = _derive_vault_key(passphrase, salt, VAULT_KDF_ITERATIONS) key = _derive_vault_key(passphrase, salt, VAULT_KDF_ITERATIONS)
plaintext = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") plaintext = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
nonce, ciphertext, tag = _encrypt_vault_payload(key, plaintext, VAULT_SCHEMA.encode("utf-8")) nonce, ciphertext, tag = _encrypt_vault_payload(key, plaintext, VAULT_SCHEMA.encode("utf-8"))
AUTH_DIR.mkdir(exist_ok=True) AUTH_DIR.mkdir(parents=True, exist_ok=True)
chmod_best_effort(AUTH_DIR, 0o700) chmod_best_effort(AUTH_DIR, 0o700)
VAULT_PATH.write_text( VAULT_PATH.write_text(
json.dumps( json.dumps(
@@ -746,7 +738,7 @@ def derive_gateway_relay_secret(passphrase):
def write_gateway_relay_secret(passphrase): def write_gateway_relay_secret(passphrase):
AUTH_DIR.mkdir(exist_ok=True) AUTH_DIR.mkdir(parents=True, exist_ok=True)
chmod_best_effort(AUTH_DIR, 0o700) chmod_best_effort(AUTH_DIR, 0o700)
GATEWAY_RELAY_SECRET_PATH.write_text( GATEWAY_RELAY_SECRET_PATH.write_text(
json.dumps( json.dumps(
@@ -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.update({key: value for key, value in updates.items() if value is not None})
current["schema"] = GATEWAY_RELAY_STATE_SCHEMA current["schema"] = GATEWAY_RELAY_STATE_SCHEMA
current["updated_at"] = iso_now() current["updated_at"] = iso_now()
AUTH_DIR.mkdir(exist_ok=True) AUTH_DIR.mkdir(parents=True, exist_ok=True)
chmod_best_effort(AUTH_DIR, 0o700) chmod_best_effort(AUTH_DIR, 0o700)
GATEWAY_RELAY_STATE_PATH.write_text(json.dumps(current, indent=2) + "\n", encoding="utf-8") GATEWAY_RELAY_STATE_PATH.write_text(json.dumps(current, indent=2) + "\n", encoding="utf-8")
chmod_best_effort(GATEWAY_RELAY_STATE_PATH, 0o600) chmod_best_effort(GATEWAY_RELAY_STATE_PATH, 0o600)
@@ -931,7 +923,7 @@ def ensure_container_uuid():
"container_uuid": value, "container_uuid": value,
"created_at": iso_now(), "created_at": iso_now(),
} }
AUTH_DIR.mkdir(exist_ok=True) AUTH_DIR.mkdir(parents=True, exist_ok=True)
chmod_best_effort(AUTH_DIR, 0o700) chmod_best_effort(AUTH_DIR, 0o700)
CONTAINER_IDENTITY_PATH.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") CONTAINER_IDENTITY_PATH.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
chmod_best_effort(CONTAINER_IDENTITY_PATH, 0o600) chmod_best_effort(CONTAINER_IDENTITY_PATH, 0o600)
@@ -1164,7 +1156,7 @@ def clean_terminal_name(value, fallback="terminal"):
def default_gateway_session_name(): def default_gateway_session_name():
source = AUTH.get("project_slug") or AUTH.get("project_name") or pathlib.Path.cwd().name or "project" source = AUTH.get("project_slug") or AUTH.get("project_name") or pathlib.Path.cwd().name or "project"
return f"napseer-{clean_tmux_name(source, 'project')}" return f"napseer-{clean_tmux_name(source, 'project')}-{WORKSPACE.workspace_id[:10]}"
def normalize_gateway_command(value=None): def normalize_gateway_command(value=None):
@@ -1727,7 +1719,7 @@ def write_gateway_schedules_state(state):
state["schema"] = GATEWAY_SCHEDULES_SCHEMA state["schema"] = GATEWAY_SCHEDULES_SCHEMA
state["version"] = 1 state["version"] = 1
state["updated_at"] = iso_now() state["updated_at"] = iso_now()
AUTH_DIR.mkdir(exist_ok=True) AUTH_DIR.mkdir(parents=True, exist_ok=True)
chmod_best_effort(AUTH_DIR, 0o700) chmod_best_effort(AUTH_DIR, 0o700)
GATEWAY_SCHEDULES_PATH.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8") GATEWAY_SCHEDULES_PATH.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
chmod_best_effort(GATEWAY_SCHEDULES_PATH, 0o600) chmod_best_effort(GATEWAY_SCHEDULES_PATH, 0o600)
@@ -5135,7 +5127,7 @@ def request_project_write(method, path, payload, project_id, purpose, scope_type
def index_file_lock(): def index_file_lock():
AUTH_DIR.mkdir(exist_ok=True) AUTH_DIR.mkdir(parents=True, exist_ok=True)
chmod_best_effort(AUTH_DIR, 0o700) chmod_best_effort(AUTH_DIR, 0o700)
handle = INDEX_LOCK_PATH.open("a+b") handle = INDEX_LOCK_PATH.open("a+b")
chmod_best_effort(INDEX_LOCK_PATH, 0o600) chmod_best_effort(INDEX_LOCK_PATH, 0o600)
@@ -5153,7 +5145,7 @@ def release_index_file_lock(handle):
def index_connect(): def index_connect():
AUTH_DIR.mkdir(exist_ok=True) AUTH_DIR.mkdir(parents=True, exist_ok=True)
chmod_best_effort(AUTH_DIR, 0o700) chmod_best_effort(AUTH_DIR, 0o700)
conn = sqlite3.connect(str(INDEX_PATH), timeout=30) conn = sqlite3.connect(str(INDEX_PATH), timeout=30)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
@@ -6032,15 +6024,16 @@ def save_auth(updates):
def ensure_local_files(): def ensure_local_files():
AUTH_DIR.mkdir(exist_ok=True) AUTH_DIR.mkdir(parents=True, exist_ok=True)
chmod_best_effort(AUTH_DIR, 0o700) chmod_best_effort(AUTH_DIR, 0o700)
gitignore = AUTH_DIR / ".gitignore" gitignore = AUTH_DIR / ".gitignore"
if not gitignore.exists(): if not gitignore.exists():
gitignore.write_text("*\n!.gitignore\n", encoding="utf-8") gitignore.write_text("*\n!.gitignore\n", encoding="utf-8")
key_path = pathlib.Path(AUTH["ssh_key_path"] or DEFAULT_KEY_PATH).expanduser() key_path = pathlib.Path(AUTH["ssh_key_path"] or DEFAULT_KEY_PATH).expanduser()
public_key_path = pathlib.Path(str(key_path) + ".pub") public_key_path = pathlib.Path(str(key_path) + ".pub")
with credential_lock(key_path):
if not key_path.exists(): if not key_path.exists():
key_path.parent.mkdir(exist_ok=True) key_path.parent.mkdir(parents=True, exist_ok=True)
chmod_best_effort(key_path.parent, 0o700) chmod_best_effort(key_path.parent, 0o700)
subprocess.run( subprocess.run(
["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key_path)], ["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key_path)],
@@ -9816,6 +9809,11 @@ def handle(message):
def cli_main(argv): def cli_main(argv):
command = argv[1] if len(argv) > 1 else "" 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"}: if command in {"configure", "config"}:
print(json.dumps(cli_configure_status(), indent=2)) print(json.dumps(cli_configure_status(), indent=2))
return return
+37
View File
@@ -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')