feat: configure agents with durable user sessions and scoped access

This commit is contained in:
HumbleTurtle
2026-09-10 03:29:48 -03:00
parent e8c6a5109e
commit 6c29a318e8
15 changed files with 1306 additions and 410 deletions
+29 -18
View File
@@ -25,9 +25,12 @@ Current state:
`tools/list`, and authenticated read probe. They deliberately report an `tools/list`, and authenticated read probe. They deliberately report an
existing client connection as `not_observable`; a successful fresh probe existing client connection as `not_observable`; a successful fresh probe
does not claim that a previously opened Codex transport is connected. does not claim that a previously opened Codex transport is connected.
- Operator OAuth credentials remain in `.napseer/auth.json`. A repaired local - `nap auth login` stores the shared account session in the user data folder
gateway uses a separate `.napseer/gateway-auth.json` worker identity, so (`~/.local/share/napseer/auth.json` on Linux; platform user data directories
relay token renewal cannot rotate or overwrite the operator/MCP session. 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`.
- `.napseer/project.json` is the commit-safe project locator. It contains only - `.napseer/project.json` is the commit-safe project locator. It contains only
the schema, API origin, project UUID, and slug; it never contains tokens, the schema, API origin, project UUID, and slug; it never contains tokens,
account or worker identity, encryption state, keys, passphrases, or claim account or worker identity, encryption state, keys, passphrases, or claim
@@ -77,28 +80,36 @@ client reconnect after updating.
The public operator surface is intentionally small: The public operator surface is intentionally small:
```text ```text
nap init | status | doctor | auth | project | mcp | gateway | update | version | help nap init | status | doctor | auth | project | files | export | mcp | gateway | update | version | help
``` ```
Authentication has one recovery verb: `nap auth repair`. Normal refresh is Authentication has one recovery verb: `nap auth repair`. Normal refresh is
automatic. `nap gateway repair` creates the separate worker identity once; automatic. `nap gateway repair` creates the separate worker identity once;
`--replace` is required to replace an existing identity. `--replace` is required to replace an existing identity.
Project-scoped MCP tools are zero-login by default. In a new workspace, the Agents start only after the user configures an account login or a delegated API
first such tool enrolls an anonymous worker and creates its first project key. With an account login, `nap init` creates a project owned by that account.
automatically; `nap init` remains the explicit equivalent. Anonymous access A committed `.napseer/project.json` always selects the existing project; it
and refresh-token or SSH-key recovery continue without operator never causes a replacement to be created. Concurrent refreshes share a bounded
authentication. Authentication through `nap project claim` is required only credential lock, while project overrides remain independent. Network failures
after the anonymous account reaches its service limit (currently one project preserve credentials and offer recovery through `nap auth repair`.
or 100 stored nodes), or when its durable local enrollment identity is no
longer recoverable.
A cloned repository that already commits `.napseer/project.json` is not a new `nap auth keys --help` describes scoped API key issuance;
workspace: `nap init` and project-scoped MCP tools fail closed instead of `nap auth api-key --env VARIABLE_NAME` installs a delegated key privately.
creating a duplicate anonymous project. Run `nap project attach`; the OAuth Keys can expire or be revoked and cannot approve agent decisions. Use separate
approval is constrained to the locator's project after normal server-side keys when agents need separate identities or permissions.
access validation. If that project is still anonymous, claim it from a machine
that retains its enrollment identity, then attach from the new computer. Upgrading from 0.2.x to 0.3.0 requires the current bootstrap once because old
installers have a fixed list of runtime files. `nap update` on those versions
rejects the new bundle before changing the active installation. Run:
```sh
curl -fsSL https://api.napseer.com/install | python3 -
```
The bootstrap verifies every asset and switches the runtime atomically without
changing credentials or project locators. Restart existing MCP client sessions
after this upgrade; subsequent releases use `nap update` normally.
In Bash, run commands as `nap update`, `nap auth login`, and so on. A leading In Bash, run commands as `nap update`, `nap auth login`, and so on. A leading
`!` is shell history expansion: `!nap update` replays the most recent command `!` is shell history expansion: `!nap update` replays the most recent command
+43 -15
View File
@@ -43,6 +43,7 @@ SCRIPT_NAMES = (
"napseer_mcp_server.py", "napseer_mcp_server.py",
"napseer_mcp_supervisor.py", "napseer_mcp_supervisor.py",
"napseer_spake2.py", "napseer_spake2.py",
"napseer_credentials.py",
"terminal_init.py", "terminal_init.py",
"terminal_protocol.py", "terminal_protocol.py",
"terminal_pty_manager.py", "terminal_pty_manager.py",
@@ -57,6 +58,7 @@ LOCAL_HOST = "127.0.0.1"
DEFAULT_GATEWAY_PORT = os.environ.get("NAPSEER_GATEWAY_PORT") DEFAULT_GATEWAY_PORT = os.environ.get("NAPSEER_GATEWAY_PORT")
RUNTIME_SCRIPT_NAMES = ( RUNTIME_SCRIPT_NAMES = (
"napseer_spake2.py", "napseer_spake2.py",
"napseer_credentials.py",
"terminal_init.py", "terminal_init.py",
"terminal_protocol.py", "terminal_protocol.py",
"terminal_pty_manager.py", "terminal_pty_manager.py",
@@ -64,15 +66,18 @@ RUNTIME_SCRIPT_NAMES = (
"napseer_mcp_supervisor.py", "napseer_mcp_supervisor.py",
) )
MAX_SERVICE_LOG_BYTES = int(os.environ.get("NAPSEER_MAX_SERVICE_LOG_BYTES", str(5 * 1024 * 1024))) MAX_SERVICE_LOG_BYTES = int(os.environ.get("NAPSEER_MAX_SERVICE_LOG_BYTES", str(5 * 1024 * 1024)))
CLI_RELEASE_VERSION = "0.2.2" CLI_RELEASE_VERSION = "0.3.0"
CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-08-01" CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-09-10"
CLI_MINIMUM_CONTRACT_VERSION = "2026-05-19" # Older installers have a fixed asset list and would omit the credentials module.
# Reject them before activation; the published bootstrap performs the upgrade.
CLI_MINIMUM_CONTRACT_VERSION = "2026-09-10"
CLI_BUNDLE_SCHEMA_VERSION = "napseer.cli.bundle.v1" CLI_BUNDLE_SCHEMA_VERSION = "napseer.cli.bundle.v1"
INSTALL_PATHS = { INSTALL_PATHS = {
"nap_install.py": "nap_install.py", "nap_install.py": "nap_install.py",
"napseer_mcp_server.py": "napseer_mcp_server.py", "napseer_mcp_server.py": "napseer_mcp_server.py",
"napseer_mcp_supervisor.py": "napseer_mcp_supervisor.py", "napseer_mcp_supervisor.py": "napseer_mcp_supervisor.py",
"napseer_spake2.py": "napseer_spake2.py", "napseer_spake2.py": "napseer_spake2.py",
"napseer_credentials.py": "napseer_credentials.py",
"terminal_init.py": "terminal/__init__.py", "terminal_init.py": "terminal/__init__.py",
"terminal_protocol.py": "terminal/protocol.py", "terminal_protocol.py": "terminal/protocol.py",
"terminal_pty_manager.py": "terminal/pty_manager.py", "terminal_pty_manager.py": "terminal/pty_manager.py",
@@ -88,6 +93,8 @@ CANONICAL_COMMANDS = (
("reindex", "Rebuild the current project's local memory index."), ("reindex", "Rebuild the current project's local memory index."),
("auth", "Authenticate an account or repair credentials."), ("auth", "Authenticate an account or repair credentials."),
("project", "Create, attach, claim, or inspect a project."), ("project", "Create, attach, claim, or inspect a project."),
("files", "List, upload, download, or archive project files."),
("export", "Export project documentation and files to a new ZIP archive."),
("mcp", "Install, update, inspect, or serve the local MCP runtime."), ("mcp", "Install, update, inspect, or serve the local MCP runtime."),
("gateway", "Set up and operate the local gateway."), ("gateway", "Set up and operate the local gateway."),
("update", "Update the installed CLI and runtime bundle."), ("update", "Update the installed CLI and runtime bundle."),
@@ -101,6 +108,8 @@ COMMAND_METADATA = {
"reindex": {"mutates": True, "visible": True}, "reindex": {"mutates": True, "visible": True},
"auth": {"mutates": True, "visible": True}, "auth": {"mutates": True, "visible": True},
"project": {"mutates": True, "visible": True}, "project": {"mutates": True, "visible": True},
"files": {"mutates": True, "visible": True},
"export": {"mutates": True, "visible": True},
"mcp": {"mutates": True, "visible": True}, "mcp": {"mutates": True, "visible": True},
"gateway": {"mutates": True, "visible": True}, "gateway": {"mutates": True, "visible": True},
"update": {"mutates": True, "visible": True}, "update": {"mutates": True, "visible": True},
@@ -503,6 +512,7 @@ def activate_release(release_dir):
"napseer_mcp_server.py", "napseer_mcp_server.py",
"napseer_mcp_supervisor.py", "napseer_mcp_supervisor.py",
"napseer_spake2.py", "napseer_spake2.py",
"napseer_credentials.py",
): ):
path = INSTALL_DIR / filename path = INSTALL_DIR / filename
preserve_legacy_path(path) preserve_legacy_path(path)
@@ -632,11 +642,15 @@ def cwd_state_dir():
def state_dir_status(): def state_dir_status():
preferred = preferred_state_dir() 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"
return { return {
"credential_source": credential_source,
"active_state_dir": str(preferred), "active_state_dir": str(preferred),
"active_auth_path": str(preferred / "auth.json"), "active_auth_path": str(auth_path),
"state_dir": str(preferred), "state_dir": str(preferred),
"auth_path": str(preferred / "auth.json"), "auth_path": str(auth_path),
"state_dir_exists": preferred.exists(), "state_dir_exists": preferred.exists(),
"state_dir_message": ( "state_dir_message": (
".napseer directory is active." ".napseer directory is active."
@@ -1077,8 +1091,17 @@ def print_command_help(command):
"Usage: nap auth [status|login|repair]\n" "Usage: nap auth [status|login|repair]\n"
" login Authenticate an account; does not select a project.\n" " login Authenticate an account; does not select a project.\n"
" status Show credential state without credential values.\n" " status Show credential state without credential values.\n"
" repair Recover failed automatic credential renewal." " 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"
" 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."
), ),
"files": "Usage: nap files list|upload|download|archive [--path FILE] [--id UUID] [--node-id UUID] [--cursor CURSOR] [--limit N]",
"export": "Usage: nap export --path NEW_ARCHIVE.zip\nExports active project nodes and files; refuses changes during export or an existing destination.",
"project": ( "project": (
"Usage: nap project [init|create|attach|claim|status|encryption]\n" "Usage: nap project [init|create|attach|claim|status|encryption]\n"
" init Initialize a fresh folder (same workflow as `nap init`).\n" " init Initialize a fresh folder (same workflow as `nap init`).\n"
@@ -1342,11 +1365,14 @@ def doctor_status():
auth_path = pathlib.Path(state["active_auth_path"]) auth_path = pathlib.Path(state["active_auth_path"])
auth = {} auth = {}
auth_error = None auth_error = None
if auth_path.exists(): try:
try: from napseer_credentials import CredentialStore, read_json
auth = json.loads(auth_path.read_text(encoding="utf-8")) store=CredentialStore(preferred_state_dir())
except (OSError, json.JSONDecodeError): auth=store.read()
auth_error = "auth file is unreadable or invalid JSON" locator=read_json(preferred_state_dir()/"project.json")
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() missing = runtime_assets_missing()
issues = [] issues = []
setup = [] setup = []
@@ -1355,13 +1381,13 @@ def doctor_status():
elif not auth_path.exists(): elif not auth_path.exists():
setup.append({ setup.append({
"code": "project_not_initialized", "code": "project_not_initialized",
"message": "current folder is not initialized; anonymous setup is available without login", "message": "current folder is not initialized; configure a user login with nap auth login",
"next": "nap init or invoke a project-scoped MCP tool", "next": "nap auth login",
}) })
elif not auth.get("project_id"): elif not auth.get("project_id"):
setup.append({ setup.append({
"code": "project_not_configured", "code": "project_not_configured",
"message": "no project is configured for this folder; anonymous setup is available without login", "message": "no project is configured for this folder; nap init uses the configured user login",
"next": "nap init", "next": "nap init",
}) })
if missing: if missing:
@@ -1466,6 +1492,8 @@ def main(argv):
return 0 return 0
if command == "gateway": if command == "gateway":
return handle_gateway(args) return handle_gateway(args)
if command in {"files", "export"}:
return run_wrapper(command, args)
if command == "status": if command == "status":
return run_wrapper("configure", args) return run_wrapper("configure", args)
if command == "project": if command == "project":
@@ -1478,7 +1506,7 @@ def main(argv):
if args and args[0] in {"login", "login-local", "operator-login"}: if args and args[0] in {"login", "login-local", "operator-login"}:
validate_cli_options( validate_cli_options(
args[1:], args[1:],
flags={"--no-browser"}, flags={"--no-browser", "--project"},
value_options={ value_options={
"--frontend-url", "--frontend-url",
"--api-base-url", "--api-base-url",
+240
View File
@@ -0,0 +1,240 @@
"""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 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"):
return Path(os.environ["NAPSEER_USER_DATA_DIR"]).expanduser().absolute()
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"
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.state_dir = Path(state_dir)
self.legacy = Path(legacy_path or self.state_dir / "auth.json")
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.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:
return Path(explicit).expanduser().absolute(), "explicit"
if self.legacy != self.state_dir / "auth.json":
return self.legacy, "explicit"
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
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):
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
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 in {"legacy_project", "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})
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
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
"""All tests use disposable user credential state, including legacy test scripts."""
import pytest
@pytest.fixture(autouse=True)
def isolated_user_credentials(monkeypatch, tmp_path):
monkeypatch.setenv("NAPSEER_USER_DATA_DIR", str(tmp_path / "user-data"))
for name in ("NAPSEER_API_KEY", "NAPSEER_TOKEN", "NAPSEER_REFRESH_TOKEN"):
monkeypatch.delenv(name, raising=False)
+127
View File
@@ -0,0 +1,127 @@
"""Public worker/CLI entry points against synthetic binary HTTP storage."""
import hashlib
import json
import os
from pathlib import Path
import subprocess
import sys
import threading
import uuid
import zipfile
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlsplit, parse_qs
import pytest
from test_user_credentials import worker, outcome, SCRIPT
from napseer_credentials import CredentialStore, atomic_json
PROJECT="11111111-1111-4111-8111-111111111111"
NODE="22222222-2222-4222-8222-222222222222"
@pytest.fixture
def storage(tmp_path):
state={"files":{},"corrupt":False,"keys":[],"projects":[]}
node={"id":NODE,"name":"Guide","full_path":"/services/guide","type":"documentation","updated_at":"2026-09-09T00:00:00Z","content_text":"Recovery procedure","metadata":{},"links":[]}
class Handler(BaseHTTPRequestHandler):
def log_message(self,*_args): pass
def reply(self,status,value,binary=False):
body=value if binary else json.dumps(value).encode()
self.send_response(status);self.send_header("Content-Length",str(len(body)));self.end_headers();self.wfile.write(body)
def do_GET(self):
path=urlsplit(self.path).path
if path=='/v1/projects':return self.reply(200,{'items':state['projects']})
if path=='/v1/api-keys':return self.reply(200,{'items':state['keys']})
if path.endswith('/nodes'): return self.reply(200,{"items":[node]})
if path.endswith('/nodes/'+NODE):return self.reply(200,node)
if path.endswith('/files'): return self.reply(200,{"items":[entry[0] for entry in state["files"].values()]})
identity=path.split('/')[-2] if path.endswith('/metadata') else path.split('/')[-1]
entry=state["files"].get(identity)
if not entry:return self.reply(404,{"error":{"code":"not_found"}})
if path.endswith('/metadata'):return self.reply(200,entry[0])
return self.reply(200,b"corrupted" if state["corrupt"] else entry[1],True)
def do_POST(self):
if self.path=='/v1/projects':
payload=json.loads(self.rfile.read(int(self.headers['Content-Length'])))
project={**payload,'id':str(uuid.uuid4()),'encryption_state':'standard'}
state['projects'].append(project);return self.reply(200,project)
if self.path=='/v1/api-keys':
payload=json.loads(self.rfile.read(int(self.headers['Content-Length'])))
key={**payload,'id':str(uuid.uuid4())};state['keys'].append(key)
return self.reply(200,{**key,'api_key':'npk_'+uuid.uuid4().hex})
query=parse_qs(urlsplit(self.path).query)
body=self.rfile.read(int(self.headers['Content-Length']))
identity=query['id'][0]
info={"id":identity,"name":query['name'][0],"node_id":query.get('node_id',[None])[0],"size_bytes":len(body),"sha256":hashlib.sha256(body).hexdigest()}
state['files'][identity]=(info,body)
return self.reply(200,info)
def do_DELETE(self):
identity=urlsplit(self.path).path.split('/')[-1];state['files'].pop(identity,None)
return self.reply(200,{"archived":True})
server=ThreadingHTTPServer(('127.0.0.1',0),Handler)
thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
store=CredentialStore(tmp_path/'.napseer')
store.login({"base_url":f"http://127.0.0.1:{server.server_port}","token":"synthetic-access","account_mode":"operator_account"})
atomic_json(store.selection,{"project_id":PROJECT})
try:yield state
finally:server.shutdown();server.server_close();thread.join(timeout=2)
def test_agent_upload_retries_download_and_export_preserve_binary_and_references(tmp_path,storage):
body=bytes([0,255,128,13,10,65]);source=tmp_path/'proof.bin';source.write_bytes(body)
args={"local_path":str(source),"node_id":NODE}
first=outcome(worker(tmp_path,f"m.call_tool_impl('nap_file_upload',{args!r})"))
assert first['ok'],first.get('code')
identity=first['result']['id']
again=outcome(worker(tmp_path,f"m.call_tool_impl('nap_file_upload',{args!r})"))
assert again['result']['id']==identity
assert len(storage['files'])==1
destination=tmp_path/'download.bin'
result=outcome(worker(tmp_path,f"m.call_tool_impl('nap_file_download',{{'id':{identity!r},'local_path':{str(destination)!r}}})"))
assert result['ok'];assert destination.read_bytes()==body
rejected=outcome(worker(tmp_path,f"m.call_tool_impl('nap_file_download',{{'id':{identity!r},'local_path':{str(source)!r}}})"))
assert rejected['code']=='destination_unavailable';assert source.read_bytes()==body
target=tmp_path/'export.zip'
env=dict(os.environ,NAPSEER_PROJECT_ROOT=str(tmp_path),NAPSEER_TELEMETRY='0')
for name in ['NAPSEER_AUTH_FILE','NAPSEER_BASE_URL','NAPSEER_PROJECT_ID']:env.pop(name,None)
exported=subprocess.run([sys.executable,str(SCRIPT),'export','--path',str(target)],cwd=tmp_path,env=env,capture_output=True,text=True,timeout=15)
assert exported.returncode==0
assert json.loads(exported.stdout)['status']=='exported'
with zipfile.ZipFile(target) as archive:
manifest=json.loads(archive.read('manifest.json'))
assert manifest['files'][0]['node_id']==NODE
assert archive.read('files/'+identity)==body
assert json.loads(archive.read('nodes/'+NODE+'.json'))['content_text']=='Recovery procedure'
assert not any('auth' in name for name in archive.namelist())
storage['corrupt']=True
rejected=outcome(worker(tmp_path,f"m.export_project({{'local_path':{str(tmp_path/'broken.zip')!r}}})"))
assert rejected['code']=='file_integrity_failed'
assert not (tmp_path/'broken.zip').exists()
assert not list(tmp_path.glob('.napseer-export-*'))
assert outcome(worker(tmp_path,f"m.call_tool_impl('nap_file_archive',{{'id':{identity!r}}})"))['ok']
assert not storage['files']
def test_cli_key_issuance_writes_private_credential_without_printing_it(tmp_path,storage):
env=dict(os.environ,NAPSEER_PROJECT_ROOT=str(tmp_path),NAPSEER_TELEMETRY='0')
for name in ['NAPSEER_AUTH_FILE','NAPSEER_BASE_URL','NAPSEER_PROJECT_ID']:env.pop(name,None)
created=subprocess.run([sys.executable,str(SCRIPT),'auth','keys','create','--project-id',PROJECT,'--name','Test delegate'],cwd=tmp_path,env=env,capture_output=True,text=True,timeout=15)
assert created.returncode==0
assert 'npk_' not in created.stdout
result=json.loads(created.stdout);path=Path(result['credential_file'])
assert path.stat().st_mode & 0o777 == 0o600
assert json.loads(path.read_text())['token'].startswith('npk_')
assert not (tmp_path/'.napseer/auth.json').exists()
def test_two_agents_bootstrap_one_project_with_general_login(tmp_path,storage):
store=CredentialStore(tmp_path/'.napseer');store.selection.unlink()
first=worker(tmp_path,"m.resolve_project_id({})")
second=worker(tmp_path,"m.resolve_project_id({})")
one,two=outcome(first),outcome(second)
assert one['ok'] and two['ok']
assert one['result']==two['result']
assert len(storage['projects'])==1
assert json.loads(store.user.read_text())['account_mode']=='operator_account'
assert 'project_id' not in json.loads(store.user.read_text())
assert not store.legacy.exists()
assert (tmp_path/'.napseer/project.json').exists()
+27 -83
View File
@@ -29,37 +29,18 @@ def anonymous_auth(mod):
} }
def test_initial_enrollment_persists_refresh_credential(): def test_unconfigured_agent_requires_user_login_without_enrollment():
mod = load_module() mod = load_module()
mod.TOKEN = None mod.TOKEN = None
mod.AUTH = anonymous_auth(mod)
mod.BASE_URL = mod.AUTH["base_url"]
mod.DEFAULT_PROJECT_ID = None
saved = []
requests = [] requests = []
mod.save_auth = lambda payload: saved.append(dict(payload)) mod.request_json = lambda *args, **kwargs: requests.append(args)
try:
def request(method, path, payload=None, token_required=False): mod.ensure_enrolled({"slug": "example"})
requests.append((method, path, payload, token_required)) except mod.SafeToolError as exc:
return { assert exc.code == "auth_required"
"token": { else:
"access_token": "access", raise AssertionError("unconfigured agents must not enroll")
"refresh_token": "refresh", assert not requests
"expires_at": "2099-01-01T00:00:00Z",
"refresh_expires_at": "",
},
"worker": {"id": "worker", "agent_id": "agent-id"},
}
mod.request_json = request
mod.ensure_enrolled({"slug": "example"})
assert saved[0]["refresh_token"] == "refresh"
assert "refresh_expires_at" in saved[0]
assert [(method, path) for method, path, _, _ in requests] == [
("POST", "/v1/enrollment/token")
]
assert requests[0][3] is False
def test_failed_anonymous_refresh_uses_ssh_recovery(): def test_failed_anonymous_refresh_uses_ssh_recovery():
@@ -107,65 +88,28 @@ def test_failed_anonymous_refresh_uses_ssh_recovery():
assert saved[0]["refresh_token"] == "recovered-refresh" assert saved[0]["refresh_token"] == "recovered-refresh"
def test_failed_mixed_state_refresh_uses_ssh_recovery(): def test_oauth_failure_never_falls_back_to_anonymous_identity():
mod = load_module() mod = load_module()
mod.AUTH = { mod.AUTH = {**anonymous_auth(mod), "account_mode": "operator_project"}
**anonymous_auth(mod), mod.TOKEN = "expired-access"
"account_mode": "operator_project", mod.REFRESH_TOKEN = "revoked-refresh"
"oauth_client_id": "nap-cli",
"project_id": "project",
"ssh_private_key": "test-private-key",
"ssh_public_key": "ssh-ed25519 test-public-key",
}
mod.BASE_URL = mod.AUTH["base_url"]
mod.DEFAULT_PROJECT_ID = "project"
mod.REFRESH_TOKEN = "stale-refresh"
mod.REFRESH_EXPIRES_AT = None
mod.refresh_public_auth_state = lambda: mod.AUTH mod.refresh_public_auth_state = lambda: mod.AUTH
mod.auth_public_key = lambda: "ssh-ed25519 test-public-key" calls = []
mod.sign_with_auth_key = lambda *args: "test-signature" def rejected(*args, **kwargs):
saved = [] raise mod.SafeToolError("http_401", "Login rejected", status=401)
mod.save_auth = lambda payload: saved.append(dict(payload)) mod.request_form_json = rejected
mod.request_json = lambda *args, **kwargs: calls.append(args)
def request_form(method, path, payload=None, token_required=False): try:
assert (method, path, token_required) == ("POST", "/v1/oauth/token", False) mod.renew_auth()
raise RuntimeError("HTTP 401") except mod.SafeToolError as exc:
assert exc.code == "auth_required"
def request(method, path, payload=None, token_required=False): else:
if path == "/v1/enrollment/refresh": raise AssertionError("OAuth revocation must require user login")
raise RuntimeError("HTTP 401") assert not calls
if path == "/v1/enrollment/challenges":
return {"challenge_id": "challenge", "challenge_text": "sign-me"}
if path == "/v1/enrollment/verify":
return {
"token": {
"access_token": "recovered-access",
"refresh_token": "recovered-refresh",
"expires_at": "2099-01-01T00:00:00Z",
"refresh_expires_at": "2099-12-31T00:00:00Z",
},
"worker": {
"id": "worker",
"agent_id": "agent-id",
"name": "agent",
"device_fingerprint": "device",
"root_path": "/repo",
"project_id": "project",
},
}
raise AssertionError(path)
mod.request_form_json = request_form
mod.request_json = request
result = mod.renew_auth()
assert result["method"] == "anonymous_ssh_recovery"
assert result["project_id"] == "project"
assert saved[0]["refresh_token"] == "recovered-refresh"
if __name__ == "__main__": if __name__ == "__main__":
test_initial_enrollment_persists_refresh_credential() test_unconfigured_agent_requires_user_login_without_enrollment()
test_failed_anonymous_refresh_uses_ssh_recovery() test_failed_anonymous_refresh_uses_ssh_recovery()
test_failed_mixed_state_refresh_uses_ssh_recovery() test_oauth_failure_never_falls_back_to_anonymous_identity()
print("ok: anonymous auth recovery tests passed") print("ok: anonymous auth recovery tests passed")
+36 -8
View File
@@ -3,6 +3,7 @@ import hashlib
import json import json
import pathlib import pathlib
import time import time
import sys
import pytest import pytest
@@ -15,6 +16,8 @@ SCRIPT_PATH = (
def load_installer(): def load_installer():
if str(SCRIPT_PATH.parent) not in sys.path:
sys.path.insert(0, str(SCRIPT_PATH.parent))
spec = importlib.util.spec_from_file_location("nap_install_command_system_test", SCRIPT_PATH) spec = importlib.util.spec_from_file_location("nap_install_command_system_test", SCRIPT_PATH)
module = importlib.util.module_from_spec(spec) module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) spec.loader.exec_module(module)
@@ -33,7 +36,7 @@ def test_root_help_exposes_only_canonical_commands(capsys):
output = capsys.readouterr().out output = capsys.readouterr().out
visible = [name for name, metadata in module.COMMAND_METADATA.items() if metadata["visible"]] visible = [name for name, metadata in module.COMMAND_METADATA.items() if metadata["visible"]]
assert visible == [name for name, _summary in module.CANONICAL_COMMANDS] assert visible == [name for name, _summary in module.CANONICAL_COMMANDS]
assert len(visible) == 11 assert len(visible) == 13
for name in visible: for name in visible:
assert f" {name}" in output assert f" {name}" in output
for hidden in ("chat", "plan", "lineage", "agent", "feedback", "where", "install"): for hidden in ("chat", "plan", "lineage", "agent", "feedback", "where", "install"):
@@ -436,7 +439,7 @@ def test_bundle_install_stages_validates_and_activates_atomically(tmp_path, monk
result = module.install_assets() result = module.install_assets()
assert result["cli_distribution"]["release_version"] == "0.2.2" assert result["cli_distribution"]["release_version"] == module.CLI_RELEASE_VERSION
assert (install_dir / "current").is_symlink() assert (install_dir / "current").is_symlink()
assert (install_dir / "current" / "bundle-manifest.json").is_file() assert (install_dir / "current" / "bundle-manifest.json").is_file()
assert (install_dir / "current" / "napseer_mcp_server.py").is_file() assert (install_dir / "current" / "napseer_mcp_server.py").is_file()
@@ -493,10 +496,10 @@ def test_manifest_accepts_a_future_compatible_release():
module = load_installer() module = load_installer()
manifest, _payloads = fake_bundle(module) manifest, _payloads = fake_bundle(module)
manifest["release_version"] = "0.2.2" manifest["release_version"] = "0.2.2"
manifest["contract"]["current"] = "2026-08-02" manifest["contract"]["current"] = "2026-09-11"
for item in manifest["items"]: for item in manifest["items"]:
item["version"] = "0.2.2" item["version"] = "0.2.2"
item["contract_version"] = "2026-08-02" item["contract_version"] = "2026-09-11"
items = module.validated_manifest_items(manifest) items = module.validated_manifest_items(manifest)
@@ -506,11 +509,11 @@ def test_manifest_accepts_a_future_compatible_release():
def test_manifest_rejects_a_minimum_contract_newer_than_installer(): def test_manifest_rejects_a_minimum_contract_newer_than_installer():
module = load_installer() module = load_installer()
manifest, _payloads = fake_bundle(module) manifest, _payloads = fake_bundle(module)
manifest["contract"]["current"] = "2026-08-02" manifest["contract"]["current"] = "2026-09-11"
manifest["contract"]["minimum_supported"] = "2026-08-02" manifest["contract"]["minimum_supported"] = "2026-09-11"
for item in manifest["items"]: for item in manifest["items"]:
item["contract_version"] = "2026-08-02" item["contract_version"] = "2026-09-11"
item["minimum_contract_version"] = "2026-08-02" item["minimum_contract_version"] = "2026-09-11"
try: try:
module.validated_manifest_items(manifest) module.validated_manifest_items(manifest)
@@ -520,6 +523,31 @@ def test_manifest_rejects_a_minimum_contract_newer_than_installer():
raise AssertionError("incompatible minimum contract must be rejected") raise AssertionError("incompatible minimum contract must be rejected")
def test_legacy_update_preserves_active_runtime_and_bootstrap_recovers(tmp_path, monkeypatch):
module = load_installer()
monkeypatch.setattr(module, "INSTALL_DIR", tmp_path / "install")
monkeypatch.setattr(module, "BIN_DIR", tmp_path / "bin")
manifest, payloads = fake_bundle(module)
previous = module.INSTALL_DIR / "releases" / "previous"
previous.mkdir(parents=True)
(previous / "nap_install.py").write_text("# existing release\n")
(module.INSTALL_DIR / "current").symlink_to(previous, target_is_directory=True)
credentials = module.INSTALL_DIR / "auth.json"
credentials.write_text('{"refresh_token":"synthetic-preserved"}')
monkeypatch.setattr(module, "bundle_manifest", lambda: manifest)
monkeypatch.setattr(module, "fetch_script", lambda name: payloads[name])
current_contract = module.CLI_DISTRIBUTION_CONTRACT_VERSION
monkeypatch.setattr(module, "CLI_DISTRIBUTION_CONTRACT_VERSION", "2026-08-01")
with pytest.raises(RuntimeError, match="newer bootstrap installer"):
module.main(["nap", "update"])
assert (module.INSTALL_DIR / "current").resolve() == previous
assert list((module.INSTALL_DIR / "releases").iterdir()) == [previous]
monkeypatch.setattr(module, "CLI_DISTRIBUTION_CONTRACT_VERSION", current_contract)
assert module.main(["nap_install.py", "mcp", "install"]) == 0
assert (module.INSTALL_DIR / "current" / "napseer_credentials.py").is_file()
assert credentials.read_text() == '{"refresh_token":"synthetic-preserved"}'
def test_cli_log_compaction_preserves_live_writer_inode(tmp_path): def test_cli_log_compaction_preserves_live_writer_inode(tmp_path):
module = load_installer() module = load_installer()
log_path = tmp_path / "gateway.log" log_path = tmp_path / "gateway.log"
+2 -2
View File
@@ -55,7 +55,7 @@ def run():
mod.DEFAULT_PROJECT_ID = None mod.DEFAULT_PROJECT_ID = None
assert mod.resolve_project_id({}) == "project-1" assert mod.resolve_project_id({}) == "project-1"
current = {} current = {"token": "configured-account", "account_mode": "operator_account"}
mod.DEFAULT_PROJECT_ID = "stale-project" mod.DEFAULT_PROJECT_ID = "stale-project"
bootstrap_calls = [] bootstrap_calls = []
@@ -75,7 +75,7 @@ def run():
} }
] ]
current = {} current = {"token": "configured-account", "account_mode": "operator_account"}
mod.DEFAULT_PROJECT_ID = None mod.DEFAULT_PROJECT_ID = None
def limited_project(_args): def limited_project(_args):
+1 -1
View File
@@ -283,7 +283,7 @@ def test_node_get_rejects_path_reads(mod):
def test_tools_include_node_by_path_descriptor(mod): def test_tools_include_node_by_path_descriptor(mod):
tool_by_name = {item["name"]: item for item in mod.tools()} tool_by_name = {item["name"]: item for item in mod.tools()}
assert len(tool_by_name) == 52 assert len(tool_by_name) == 58
assert len(tool_by_name) == len(mod.tools()) assert len(tool_by_name) == len(mod.tools())
get_tool = next(item for item in mod.tools() if item["name"] == "nap_node_get") get_tool = next(item for item in mod.tools() if item["name"] == "nap_node_get")
+2 -1
View File
@@ -32,9 +32,10 @@ def mod(monkeypatch):
return module return module
def test_default_surface_is_exactly_the_core_fifteen(mod): def test_default_surface_includes_knowledge_files_and_coordination(mod):
assert [tool["name"] for tool in mod.tools()] == [ assert [tool["name"] for tool in mod.tools()] == [
"nap_apropos", "nap_man", "nap_doctor", "nap_whoami", "nap_apropos", "nap_man", "nap_doctor", "nap_whoami",
"nap_operation_list", "nap_operation", "nap_file_list", "nap_file_upload", "nap_file_download", "nap_file_archive",
"nap_discover", "nap_context", "nap_node_by_path", "nap_node_get", "nap_discover", "nap_context", "nap_node_by_path", "nap_node_get",
"nap_create_node", "nap_node_patch", "nap_bulk", "nap_batch", "nap_ln", "nap_create_node", "nap_node_patch", "nap_bulk", "nap_batch", "nap_ln",
"nap_mv", "nap_rm", "nap_mv", "nap_rm",
+4 -2
View File
@@ -2,6 +2,7 @@
"""Smoke-test existing-project OAuth attachment for folder bootstrap.""" """Smoke-test existing-project OAuth attachment for folder bootstrap."""
import importlib.util import importlib.util
import json
import pathlib import pathlib
import sys import sys
import tempfile import tempfile
@@ -59,12 +60,12 @@ def run():
result = mod.operator_project_attach({"open_browser": False}) result = mod.operator_project_attach({"open_browser": False})
saves.insert(0, json.loads(pathlib.Path(result["auth_path"]).read_text()))
assert saves[0]["token"] == "oauth-token" assert saves[0]["token"] == "oauth-token"
assert saves[0]["refresh_token"] == "oauth-refresh-token" assert saves[0]["refresh_token"] == "oauth-refresh-token"
assert saves[0]["project_id"] == PROJECT_ID assert saves[0]["project_id"] == PROJECT_ID
assert saves[0]["account_mode"] == "operator_project" assert saves[0]["account_mode"] == "operator_project"
assert replacements[0] == mod.WORKER_BINDING_AUTH_KEYS assert mod.WORKER_BINDING_AUTH_KEYS.isdisjoint(saves[0])
assert "project_id" not in replacements[0]
assert "napseer.projects.read" in saves[0]["oauth_scope"] assert "napseer.projects.read" in saves[0]["oauth_scope"]
assert saves[1]["project_slug"] == "existing-project" assert saves[1]["project_slug"] == "existing-project"
assert result["project_id"] == PROJECT_ID assert result["project_id"] == PROJECT_ID
@@ -81,6 +82,7 @@ def run():
"scope": args["scope"], "scope": args["scope"],
} }
result = mod.operator_account_login({"open_browser": False}) result = mod.operator_account_login({"open_browser": False})
saves.insert(0, json.loads(pathlib.Path(result["auth_path"]).read_text()))
assert saves[0]["refresh_token"] == "account-refresh-token" assert saves[0]["refresh_token"] == "account-refresh-token"
assert saves[0]["account_mode"] == "operator_account" assert saves[0]["account_mode"] == "operator_account"
assert "project_id" not in saves[0] assert "project_id" not in saves[0]
+5 -3
View File
@@ -59,6 +59,7 @@ def test_account_login_requests_create_and_runtime_scopes():
result = mod.operator_account_login({"open_browser": False}) result = mod.operator_account_login({"open_browser": False})
saves.append(json.loads(pathlib.Path(result["auth_path"]).read_text()))
requested = set(captured[0]["scope"].split()) requested = set(captured[0]["scope"].split())
assert "napseer.projects.write" in requested assert "napseer.projects.write" in requested
assert set(mod.LOCAL_PROJECT_OAUTH_SCOPE.split()).issubset(requested) assert set(mod.LOCAL_PROJECT_OAUTH_SCOPE.split()).issubset(requested)
@@ -84,7 +85,7 @@ def test_legacy_account_scope_fails_before_http_with_recovery():
assert not requests assert not requests
def test_successful_create_transitions_local_state_to_project_mode(): def test_successful_create_preserves_general_account_mode():
mod = load_module() mod = load_module()
configure_account_state(mod, mod.OPERATOR_ACCOUNT_OAUTH_SCOPE) configure_account_state(mod, mod.OPERATOR_ACCOUNT_OAUTH_SCOPE)
saves = [] saves = []
@@ -102,7 +103,8 @@ def test_successful_create_transitions_local_state_to_project_mode():
assert status == "created" assert status == "created"
assert project["id"] == PROJECT_ID assert project["id"] == PROJECT_ID
assert saves[0]["project_id"] == PROJECT_ID assert saves[0]["project_id"] == PROJECT_ID
assert saves[0]["account_mode"] == "operator_project" assert "account_mode" not in saves[0]
assert mod.AUTH["account_mode"] == "operator_account"
assert locators == [project] assert locators == [project]
@@ -162,7 +164,7 @@ def test_anonymous_limits_are_actionable_without_exposing_response_details():
if __name__ == "__main__": if __name__ == "__main__":
test_account_login_requests_create_and_runtime_scopes() test_account_login_requests_create_and_runtime_scopes()
test_legacy_account_scope_fails_before_http_with_recovery() test_legacy_account_scope_fails_before_http_with_recovery()
test_successful_create_transitions_local_state_to_project_mode() test_successful_create_preserves_general_account_mode()
test_server_scope_denial_is_actionable() test_server_scope_denial_is_actionable()
test_anonymous_limits_are_actionable_without_exposing_response_details() test_anonymous_limits_are_actionable_without_exposing_response_details()
print("ok: project create OAuth lifecycle passed") print("ok: project create OAuth lifecycle passed")
+2 -1
View File
@@ -169,7 +169,8 @@ def test_attach_forwards_and_verifies_repository_project_hint():
assert oauth_calls[0]["project_id_hint"] == PROJECT_ID assert oauth_calls[0]["project_id_hint"] == PROJECT_ID
assert result["project_id"] == PROJECT_ID assert result["project_id"] == PROJECT_ID
assert result["project_locator"]["project_id"] == PROJECT_ID assert result["project_locator"]["project_id"] == PROJECT_ID
assert saves[0]["project_id"] == PROJECT_ID credentials = json.loads(pathlib.Path(result["auth_path"]).read_text())
assert credentials["project_id"] == PROJECT_ID
if __name__ == "__main__": if __name__ == "__main__":
+284
View File
@@ -0,0 +1,284 @@
"""Real workers and synthetic HTTP provider: user sessions across repositories."""
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs
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
@pytest.fixture
def provider():
state = {"refreshes": 0, "requests": 0, "failure": None, "revoked": False}
mutex = threading.Lock()
state["refresh_started"] = threading.Event()
state["refresh_release"] = threading.Event()
class Handler(BaseHTTPRequestHandler):
def log_message(self, *_args):
pass
def reply(self, status, value):
data = json.dumps(value).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self):
with mutex:
state["requests"] += 1
if self.headers.get("Authorization") == "Bearer fresh-access" and not state["revoked"]:
self.reply(200, {"ok": True})
else:
self.reply(401, {"error": {"code": "unauthorized"}})
def do_POST(self):
form = parse_qs(self.rfile.read(int(self.headers["Content-Length"])).decode())
if self.path == "/v1/oauth/revoke":
state["revoked"] = True
return self.reply(200, {})
assert self.path == "/v1/oauth/token", "no enrollment fallback is allowed"
if state["failure"] == "transport":
return self.reply(503, {"error": {"code": "unavailable"}})
if state["failure"] == "malformed":
return self.reply(200, {"access_token": "incomplete"})
if form.get("refresh_token") != ["old-refresh"] or state["refreshes"]:
return self.reply(401, {"error": {"code": "invalid_grant"}})
if state.get("hold_refresh"):
state["refresh_started"].set()
assert state["refresh_release"].wait(5)
with mutex:
state["refreshes"] += 1
self.reply(200, {"access_token": "fresh-access", "refresh_token": "fresh-refresh",
"expires_in": 3600, "refresh_expires_at": "2099-01-01T00:00:00Z"})
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_port}", state
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
def seed(root, url):
root.mkdir(exist_ok=True)
store = CredentialStore(root / ".napseer")
store.login({"base_url": url, "token": "old-access", "refresh_token": "old-refresh",
"account_mode": "operator_account", "oauth_client_id": "nap-cli"})
return store
def worker(root, expression):
code = "import importlib.util, json, sys\n" + (
"spec = importlib.util.spec_from_file_location('worker', sys.argv[1])\n"
"m = importlib.util.module_from_spec(spec)\nspec.loader.exec_module(m)\n"
"try:\n result = " + expression + "\n print(json.dumps({'ok': True, 'result': result}))\n"
"except Exception as exc:\n print(json.dumps({'ok': False, 'code': getattr(exc, 'code', type(exc).__name__)}))\n"
)
env = dict(os.environ, NAPSEER_PROJECT_ROOT=str(root), NAPSEER_TELEMETRY="0")
for key in ("NAPSEER_AUTH_FILE", "NAPSEER_BASE_URL", "NAPSEER_PROJECT_ID"):
env.pop(key, None)
return subprocess.Popen([sys.executable, "-c", code, str(SCRIPT)], cwd=root, env=env,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
def outcome(process):
out, _err = process.communicate(timeout=15)
assert process.returncode == 0, "worker must exit without exposing credential state"
return json.loads(out)
def test_shared_login_refreshes_once_across_real_processes_and_survives_restart(tmp_path, provider):
url, state = provider
first, second = tmp_path / "first", tmp_path / "second"
store = seed(first, url)
second.mkdir()
processes = [worker(root, "m.request_json('GET', '/v1/projects')") for root in [first, second]]
assert all(outcome(p)["ok"] for p in processes)
assert state["refreshes"] == 1
assert outcome(worker(second, "m.request_json('GET', '/v1/projects')"))["ok"]
assert state["refreshes"] == 1
assert store.user.stat().st_mode & 0o777 == 0o600
assert not (first / ".napseer/auth.json").exists()
assert not (second / ".napseer/auth.json").exists()
@pytest.mark.parametrize("failure", ["transport", "malformed"])
def test_refresh_failure_preserves_credentials_and_recovers(tmp_path, provider, failure):
url, state = provider
store = seed(tmp_path, url)
before = store.user.read_bytes()
state["failure"] = failure
assert not outcome(worker(tmp_path, "m.request_json('GET', '/v1/projects')"))["ok"]
assert store.user.read_bytes() == before
state["failure"] = None
assert outcome(worker(tmp_path, "m.request_json('GET', '/v1/projects')"))["ok"]
def test_project_override_does_not_fall_back_to_user_authority(tmp_path, provider):
url, state = provider
store = seed(tmp_path, url)
store.login({"base_url": url, "token": "npk_expired", "account_mode": "api_key"}, project=True)
result = outcome(worker(tmp_path, "m.request_json('GET', '/v1/projects')"))
assert not result["ok"]
assert state["refreshes"] == 0
assert read_json(store.user)["token"] == "old-access"
store.logout(project=True)
assert not outcome(worker(tmp_path, "m.request_json('GET', '/v1/projects')"))["ok"]
assert state["refreshes"] == 0
def test_unconfigured_agent_never_enrolls_or_creates_project(tmp_path):
result = outcome(worker(tmp_path, "m.resolve_project_id({})"))
assert result == {"ok": False, "code": "auth_required"}
assert not (tmp_path / ".napseer").exists()
def test_logout_revokes_and_next_worker_observes_tombstone(tmp_path, provider):
url, state = provider
store = seed(tmp_path, url)
assert outcome(worker(tmp_path, "m.operator_logout({})"))["ok"]
assert state["revoked"]
assert read_json(store.user)["logged_out"]
assert not outcome(worker(tmp_path, "m.request_json('GET', '/v1/projects')"))["ok"]
assert state["requests"] == 0
def test_global_credentials_keep_repository_selections_independent(tmp_path, provider):
url, _ = provider
first = tmp_path / "first"
store = seed(first, url)
other = CredentialStore(tmp_path / "second/.napseer")
store.write({**store.read(), "project_id": "first"})
other.write({**other.read(), "project_id": "second"})
assert "project_id" not in read_json(store.user)
assert store.read()["project_id"] == "first"
assert other.read()["project_id"] == "second"
def test_logout_waits_for_inflight_rotation_and_cannot_be_undone(tmp_path, provider):
url,state=provider
store=seed(tmp_path,url)
state["hold_refresh"]=True
refresh=worker(tmp_path,"m.request_json('GET','/v1/projects')")
assert state["refresh_started"].wait(5)
logout=worker(tmp_path,"m.operator_logout({})")
state["refresh_release"].set()
outcome(refresh)
assert outcome(logout)["ok"]
assert state["revoked"]
assert read_json(store.user).get("logged_out")
assert not outcome(worker(tmp_path,"m.request_json('GET','/v1/projects')"))["ok"]
def test_refresh_pins_credential_source_when_override_appears(tmp_path, provider):
url,state=provider
store=seed(tmp_path,url)
state["hold_refresh"]=True
refresh=worker(tmp_path,"m.request_json('GET','/v1/projects')")
assert state["refresh_started"].wait(5)
override={"base_url":url,"token":"npk_project_test","account_mode":"api_key"}
store.login(override,project=True)
state["refresh_release"].set()
assert not outcome(refresh)["ok"], "retry observes the explicit override"
assert read_json(store.project)==override
assert read_json(store.user)["token"]=="fresh-access"
def test_legacy_migration_preserves_authority_and_general_selection_is_explicit(tmp_path,provider):
url,_=provider
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"
store.migrate()
assert not store.legacy.exists()
assert store.read()["token"]=="legacy-project"
store.use_user()
assert store.read()["token"]=="old-access"
assert read_json(store.project)==legacy
def test_failed_login_initialization_releases_actual_loopback_socket(tmp_path,monkeypatch):
import socket
spec=importlib.util.spec_from_file_location("cleanup_worker",SCRIPT)
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
reserved=socket.socket();reserved.bind(("127.0.0.1",0));port=reserved.getsockname()[1];reserved.close()
monkeypatch.setattr(module,"request_json",lambda *a,**k: (_ for _ in ()).throw(RuntimeError("synthetic unavailable")))
with pytest.raises(RuntimeError):
module.oauth_loopback_authorize({"scope":"test","flow":"operator_login","port":port,"open_browser":False})
probe=socket.socket()
try: probe.bind(("127.0.0.1",port))
finally: probe.close()
def test_native_login_uses_requested_service_pkce_callback_and_shared_store(tmp_path,monkeypatch):
import base64
import hashlib
import urllib.request
from urllib.parse import urlsplit, urlencode
state={}
class Handler(BaseHTTPRequestHandler):
def log_message(self,*_args): pass
def do_POST(self):
raw=self.rfile.read(int(self.headers['Content-Length']))
if self.path.endswith('/authorizations'):
state['authorization']=json.loads(raw)
payload={"authorization_url":"http://synthetic-browser.invalid/consent"}
else:
form=parse_qs(raw.decode());state['exchanged']=True
expected=base64.urlsafe_b64encode(hashlib.sha256(form['code_verifier'][0].encode()).digest()).decode().rstrip('=')
assert expected==state['authorization']['code_challenge']
assert form['code']==['synthetic-one-time-code']
payload={"access_token":"synthetic-native-access","refresh_token":"synthetic-native-refresh","expires_in":3600}
body=json.dumps(payload).encode();self.send_response(200);self.end_headers();self.wfile.write(body)
server=ThreadingHTTPServer(('127.0.0.1',0),Handler)
thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
spec=importlib.util.spec_from_file_location('native_login_worker',SCRIPT)
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
monkeypatch.setattr(module,'AUTH_DIR',tmp_path/'.napseer')
monkeypatch.setattr(module,'AUTH_PATH',tmp_path/'.napseer/auth.json')
monkeypatch.setattr(module,'BASE_URL','http://127.0.0.1:1')
store = CredentialStore(tmp_path/'.napseer')
store.user.parent.mkdir(parents=True,exist_ok=True)
store.user.write_text('{broken synthetic state')
def consent(_url):
grant=state['authorization']
callback=grant['redirect_uri']+'?'+urlencode({'code':'synthetic-one-time-code','state':grant['state']})
with urllib.request.urlopen(callback,timeout=3) as response: assert response.status==200
return True
monkeypatch.setattr(module.webbrowser,'open',consent)
try:
result=module.operator_account_login({'base_url':f'http://127.0.0.1:{server.server_port}','timeout_seconds':5})
assert result['status']=='authenticated';assert state['exchanged']
store=CredentialStore(tmp_path/'.napseer')
assert store.user.exists();assert not store.legacy.exists()
assert read_json(store.user)['base_url']==f'http://127.0.0.1:{server.server_port}'
finally:server.shutdown();server.server_close();thread.join(timeout=2)
def test_corrupt_credentials_allow_help_but_never_authenticated_fallback(tmp_path):
store=CredentialStore(tmp_path/'.napseer')
store.user.parent.mkdir(parents=True,exist_ok=True)
store.user.write_text('{broken synthetic state')
result=outcome(worker(tmp_path,"m.request_json('GET','/v1/projects')"))
assert result['code']=='CredentialError'
assert store.user.read_text()=='{broken synthetic state'
env=dict(os.environ,NAPSEER_PROJECT_ROOT=str(tmp_path))
help_result=subprocess.run([sys.executable,str(SCRIPT),'auth','--help'],cwd=tmp_path,env=env,capture_output=True,text=True,timeout=10)
assert help_result.returncode==0
assert 'nap auth login' in help_result.stdout