feat: configure agents with durable user sessions and scoped access
This commit is contained in:
@@ -25,9 +25,12 @@ Current state:
|
||||
`tools/list`, and authenticated read probe. They deliberately report an
|
||||
existing client connection as `not_observable`; a successful fresh probe
|
||||
does not claim that a previously opened Codex transport is connected.
|
||||
- Operator OAuth credentials remain in `.napseer/auth.json`. A repaired local
|
||||
gateway uses a separate `.napseer/gateway-auth.json` worker identity, so
|
||||
relay token renewal cannot rotate or overwrite the operator/MCP session.
|
||||
- `nap auth login` stores the shared account session in the user data folder
|
||||
(`~/.local/share/napseer/auth.json` on Linux; platform user data directories
|
||||
on macOS/Windows). `NAPSEER_USER_DATA_DIR` overrides that location.
|
||||
`nap auth login --project` selects a local override. Legacy repository
|
||||
credentials remain an explicit override until `nap auth migrate` moves them.
|
||||
Gateway identities remain separate in `.napseer/gateway-auth.json`.
|
||||
- `.napseer/project.json` is the commit-safe project locator. It contains only
|
||||
the schema, API origin, project UUID, and slug; it never contains tokens,
|
||||
account or worker identity, encryption state, keys, passphrases, or claim
|
||||
@@ -77,28 +80,36 @@ client reconnect after updating.
|
||||
The public operator surface is intentionally small:
|
||||
|
||||
```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
|
||||
automatic. `nap gateway repair` creates the separate worker identity once;
|
||||
`--replace` is required to replace an existing identity.
|
||||
|
||||
Project-scoped MCP tools are zero-login by default. In a new workspace, the
|
||||
first such tool enrolls an anonymous worker and creates its first project
|
||||
automatically; `nap init` remains the explicit equivalent. Anonymous access
|
||||
and refresh-token or SSH-key recovery continue without operator
|
||||
authentication. Authentication through `nap project claim` is required only
|
||||
after the anonymous account reaches its service limit (currently one project
|
||||
or 100 stored nodes), or when its durable local enrollment identity is no
|
||||
longer recoverable.
|
||||
Agents start only after the user configures an account login or a delegated API
|
||||
key. With an account login, `nap init` creates a project owned by that account.
|
||||
A committed `.napseer/project.json` always selects the existing project; it
|
||||
never causes a replacement to be created. Concurrent refreshes share a bounded
|
||||
credential lock, while project overrides remain independent. Network failures
|
||||
preserve credentials and offer recovery through `nap auth repair`.
|
||||
|
||||
A cloned repository that already commits `.napseer/project.json` is not a new
|
||||
workspace: `nap init` and project-scoped MCP tools fail closed instead of
|
||||
creating a duplicate anonymous project. Run `nap project attach`; the OAuth
|
||||
approval is constrained to the locator's project after normal server-side
|
||||
access validation. If that project is still anonymous, claim it from a machine
|
||||
that retains its enrollment identity, then attach from the new computer.
|
||||
`nap auth keys --help` describes scoped API key issuance;
|
||||
`nap auth api-key --env VARIABLE_NAME` installs a delegated key privately.
|
||||
Keys can expire or be revoked and cannot approve agent decisions. Use separate
|
||||
keys when agents need separate identities or permissions.
|
||||
|
||||
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
|
||||
`!` is shell history expansion: `!nap update` replays the most recent command
|
||||
|
||||
@@ -43,6 +43,7 @@ SCRIPT_NAMES = (
|
||||
"napseer_mcp_server.py",
|
||||
"napseer_mcp_supervisor.py",
|
||||
"napseer_spake2.py",
|
||||
"napseer_credentials.py",
|
||||
"terminal_init.py",
|
||||
"terminal_protocol.py",
|
||||
"terminal_pty_manager.py",
|
||||
@@ -57,6 +58,7 @@ LOCAL_HOST = "127.0.0.1"
|
||||
DEFAULT_GATEWAY_PORT = os.environ.get("NAPSEER_GATEWAY_PORT")
|
||||
RUNTIME_SCRIPT_NAMES = (
|
||||
"napseer_spake2.py",
|
||||
"napseer_credentials.py",
|
||||
"terminal_init.py",
|
||||
"terminal_protocol.py",
|
||||
"terminal_pty_manager.py",
|
||||
@@ -64,15 +66,18 @@ RUNTIME_SCRIPT_NAMES = (
|
||||
"napseer_mcp_supervisor.py",
|
||||
)
|
||||
MAX_SERVICE_LOG_BYTES = int(os.environ.get("NAPSEER_MAX_SERVICE_LOG_BYTES", str(5 * 1024 * 1024)))
|
||||
CLI_RELEASE_VERSION = "0.2.2"
|
||||
CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-08-01"
|
||||
CLI_MINIMUM_CONTRACT_VERSION = "2026-05-19"
|
||||
CLI_RELEASE_VERSION = "0.3.0"
|
||||
CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-09-10"
|
||||
# Older installers have a fixed asset list and would omit the credentials module.
|
||||
# Reject them before activation; the published bootstrap performs the upgrade.
|
||||
CLI_MINIMUM_CONTRACT_VERSION = "2026-09-10"
|
||||
CLI_BUNDLE_SCHEMA_VERSION = "napseer.cli.bundle.v1"
|
||||
INSTALL_PATHS = {
|
||||
"nap_install.py": "nap_install.py",
|
||||
"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": "napseer_credentials.py",
|
||||
"terminal_init.py": "terminal/__init__.py",
|
||||
"terminal_protocol.py": "terminal/protocol.py",
|
||||
"terminal_pty_manager.py": "terminal/pty_manager.py",
|
||||
@@ -88,6 +93,8 @@ CANONICAL_COMMANDS = (
|
||||
("reindex", "Rebuild the current project's local memory index."),
|
||||
("auth", "Authenticate an account or repair credentials."),
|
||||
("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."),
|
||||
("gateway", "Set up and operate the local gateway."),
|
||||
("update", "Update the installed CLI and runtime bundle."),
|
||||
@@ -101,6 +108,8 @@ COMMAND_METADATA = {
|
||||
"reindex": {"mutates": True, "visible": True},
|
||||
"auth": {"mutates": True, "visible": True},
|
||||
"project": {"mutates": True, "visible": True},
|
||||
"files": {"mutates": True, "visible": True},
|
||||
"export": {"mutates": True, "visible": True},
|
||||
"mcp": {"mutates": True, "visible": True},
|
||||
"gateway": {"mutates": True, "visible": True},
|
||||
"update": {"mutates": True, "visible": True},
|
||||
@@ -503,6 +512,7 @@ def activate_release(release_dir):
|
||||
"napseer_mcp_server.py",
|
||||
"napseer_mcp_supervisor.py",
|
||||
"napseer_spake2.py",
|
||||
"napseer_credentials.py",
|
||||
):
|
||||
path = INSTALL_DIR / filename
|
||||
preserve_legacy_path(path)
|
||||
@@ -632,11 +642,15 @@ def cwd_state_dir():
|
||||
|
||||
def state_dir_status():
|
||||
preferred = preferred_state_dir()
|
||||
from napseer_credentials import CredentialStore
|
||||
try: auth_path, credential_source = CredentialStore(preferred).selected()
|
||||
except RuntimeError: auth_path, credential_source = CredentialStore(preferred).user, "invalid_selection"
|
||||
return {
|
||||
"credential_source": credential_source,
|
||||
"active_state_dir": str(preferred),
|
||||
"active_auth_path": str(preferred / "auth.json"),
|
||||
"active_auth_path": str(auth_path),
|
||||
"state_dir": str(preferred),
|
||||
"auth_path": str(preferred / "auth.json"),
|
||||
"auth_path": str(auth_path),
|
||||
"state_dir_exists": preferred.exists(),
|
||||
"state_dir_message": (
|
||||
".napseer directory is active."
|
||||
@@ -1077,8 +1091,17 @@ def print_command_help(command):
|
||||
"Usage: nap auth [status|login|repair]\n"
|
||||
" login Authenticate an account; does not select a project.\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": (
|
||||
"Usage: nap project [init|create|attach|claim|status|encryption]\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 = {}
|
||||
auth_error = None
|
||||
if auth_path.exists():
|
||||
try:
|
||||
auth = json.loads(auth_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
auth_error = "auth file is unreadable or invalid JSON"
|
||||
try:
|
||||
from napseer_credentials import CredentialStore, read_json
|
||||
store=CredentialStore(preferred_state_dir())
|
||||
auth=store.read()
|
||||
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()
|
||||
issues = []
|
||||
setup = []
|
||||
@@ -1355,13 +1381,13 @@ def doctor_status():
|
||||
elif not auth_path.exists():
|
||||
setup.append({
|
||||
"code": "project_not_initialized",
|
||||
"message": "current folder is not initialized; anonymous setup is available without login",
|
||||
"next": "nap init or invoke a project-scoped MCP tool",
|
||||
"message": "current folder is not initialized; configure a user login with nap auth login",
|
||||
"next": "nap auth login",
|
||||
})
|
||||
elif not auth.get("project_id"):
|
||||
setup.append({
|
||||
"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",
|
||||
})
|
||||
if missing:
|
||||
@@ -1466,6 +1492,8 @@ def main(argv):
|
||||
return 0
|
||||
if command == "gateway":
|
||||
return handle_gateway(args)
|
||||
if command in {"files", "export"}:
|
||||
return run_wrapper(command, args)
|
||||
if command == "status":
|
||||
return run_wrapper("configure", args)
|
||||
if command == "project":
|
||||
@@ -1478,7 +1506,7 @@ def main(argv):
|
||||
if args and args[0] in {"login", "login-local", "operator-login"}:
|
||||
validate_cli_options(
|
||||
args[1:],
|
||||
flags={"--no-browser"},
|
||||
flags={"--no-browser", "--project"},
|
||||
value_options={
|
||||
"--frontend-url",
|
||||
"--api-base-url",
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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.TOKEN = None
|
||||
mod.AUTH = anonymous_auth(mod)
|
||||
mod.BASE_URL = mod.AUTH["base_url"]
|
||||
mod.DEFAULT_PROJECT_ID = None
|
||||
saved = []
|
||||
requests = []
|
||||
mod.save_auth = lambda payload: saved.append(dict(payload))
|
||||
|
||||
def request(method, path, payload=None, token_required=False):
|
||||
requests.append((method, path, payload, token_required))
|
||||
return {
|
||||
"token": {
|
||||
"access_token": "access",
|
||||
"refresh_token": "refresh",
|
||||
"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
|
||||
mod.request_json = lambda *args, **kwargs: requests.append(args)
|
||||
try:
|
||||
mod.ensure_enrolled({"slug": "example"})
|
||||
except mod.SafeToolError as exc:
|
||||
assert exc.code == "auth_required"
|
||||
else:
|
||||
raise AssertionError("unconfigured agents must not enroll")
|
||||
assert not requests
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
def test_failed_mixed_state_refresh_uses_ssh_recovery():
|
||||
def test_oauth_failure_never_falls_back_to_anonymous_identity():
|
||||
mod = load_module()
|
||||
mod.AUTH = {
|
||||
**anonymous_auth(mod),
|
||||
"account_mode": "operator_project",
|
||||
"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.AUTH = {**anonymous_auth(mod), "account_mode": "operator_project"}
|
||||
mod.TOKEN = "expired-access"
|
||||
mod.REFRESH_TOKEN = "revoked-refresh"
|
||||
mod.refresh_public_auth_state = lambda: mod.AUTH
|
||||
mod.auth_public_key = lambda: "ssh-ed25519 test-public-key"
|
||||
mod.sign_with_auth_key = lambda *args: "test-signature"
|
||||
saved = []
|
||||
mod.save_auth = lambda payload: saved.append(dict(payload))
|
||||
|
||||
def request_form(method, path, payload=None, token_required=False):
|
||||
assert (method, path, token_required) == ("POST", "/v1/oauth/token", False)
|
||||
raise RuntimeError("HTTP 401")
|
||||
|
||||
def request(method, path, payload=None, token_required=False):
|
||||
if path == "/v1/enrollment/refresh":
|
||||
raise RuntimeError("HTTP 401")
|
||||
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"
|
||||
calls = []
|
||||
def rejected(*args, **kwargs):
|
||||
raise mod.SafeToolError("http_401", "Login rejected", status=401)
|
||||
mod.request_form_json = rejected
|
||||
mod.request_json = lambda *args, **kwargs: calls.append(args)
|
||||
try:
|
||||
mod.renew_auth()
|
||||
except mod.SafeToolError as exc:
|
||||
assert exc.code == "auth_required"
|
||||
else:
|
||||
raise AssertionError("OAuth revocation must require user login")
|
||||
assert not calls
|
||||
|
||||
|
||||
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_mixed_state_refresh_uses_ssh_recovery()
|
||||
test_oauth_failure_never_falls_back_to_anonymous_identity()
|
||||
print("ok: anonymous auth recovery tests passed")
|
||||
|
||||
@@ -3,6 +3,7 @@ import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import time
|
||||
import sys
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -15,6 +16,8 @@ SCRIPT_PATH = (
|
||||
|
||||
|
||||
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)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
@@ -33,7 +36,7 @@ def test_root_help_exposes_only_canonical_commands(capsys):
|
||||
output = capsys.readouterr().out
|
||||
visible = [name for name, metadata in module.COMMAND_METADATA.items() if metadata["visible"]]
|
||||
assert visible == [name for name, _summary in module.CANONICAL_COMMANDS]
|
||||
assert len(visible) == 11
|
||||
assert len(visible) == 13
|
||||
for name in visible:
|
||||
assert f" {name}" in output
|
||||
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()
|
||||
|
||||
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" / "bundle-manifest.json").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()
|
||||
manifest, _payloads = fake_bundle(module)
|
||||
manifest["release_version"] = "0.2.2"
|
||||
manifest["contract"]["current"] = "2026-08-02"
|
||||
manifest["contract"]["current"] = "2026-09-11"
|
||||
for item in manifest["items"]:
|
||||
item["version"] = "0.2.2"
|
||||
item["contract_version"] = "2026-08-02"
|
||||
item["contract_version"] = "2026-09-11"
|
||||
|
||||
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():
|
||||
module = load_installer()
|
||||
manifest, _payloads = fake_bundle(module)
|
||||
manifest["contract"]["current"] = "2026-08-02"
|
||||
manifest["contract"]["minimum_supported"] = "2026-08-02"
|
||||
manifest["contract"]["current"] = "2026-09-11"
|
||||
manifest["contract"]["minimum_supported"] = "2026-09-11"
|
||||
for item in manifest["items"]:
|
||||
item["contract_version"] = "2026-08-02"
|
||||
item["minimum_contract_version"] = "2026-08-02"
|
||||
item["contract_version"] = "2026-09-11"
|
||||
item["minimum_contract_version"] = "2026-09-11"
|
||||
|
||||
try:
|
||||
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")
|
||||
|
||||
|
||||
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):
|
||||
module = load_installer()
|
||||
log_path = tmp_path / "gateway.log"
|
||||
|
||||
@@ -55,7 +55,7 @@ def run():
|
||||
mod.DEFAULT_PROJECT_ID = None
|
||||
assert mod.resolve_project_id({}) == "project-1"
|
||||
|
||||
current = {}
|
||||
current = {"token": "configured-account", "account_mode": "operator_account"}
|
||||
mod.DEFAULT_PROJECT_ID = "stale-project"
|
||||
bootstrap_calls = []
|
||||
|
||||
@@ -75,7 +75,7 @@ def run():
|
||||
}
|
||||
]
|
||||
|
||||
current = {}
|
||||
current = {"token": "configured-account", "account_mode": "operator_account"}
|
||||
mod.DEFAULT_PROJECT_ID = None
|
||||
|
||||
def limited_project(_args):
|
||||
|
||||
@@ -283,7 +283,7 @@ def test_node_get_rejects_path_reads(mod):
|
||||
|
||||
def test_tools_include_node_by_path_descriptor(mod):
|
||||
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())
|
||||
|
||||
get_tool = next(item for item in mod.tools() if item["name"] == "nap_node_get")
|
||||
|
||||
@@ -32,9 +32,10 @@ def mod(monkeypatch):
|
||||
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()] == [
|
||||
"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_create_node", "nap_node_patch", "nap_bulk", "nap_batch", "nap_ln",
|
||||
"nap_mv", "nap_rm",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"""Smoke-test existing-project OAuth attachment for folder bootstrap."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -59,12 +60,12 @@ def run():
|
||||
|
||||
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]["refresh_token"] == "oauth-refresh-token"
|
||||
assert saves[0]["project_id"] == PROJECT_ID
|
||||
assert saves[0]["account_mode"] == "operator_project"
|
||||
assert replacements[0] == mod.WORKER_BINDING_AUTH_KEYS
|
||||
assert "project_id" not in replacements[0]
|
||||
assert mod.WORKER_BINDING_AUTH_KEYS.isdisjoint(saves[0])
|
||||
assert "napseer.projects.read" in saves[0]["oauth_scope"]
|
||||
assert saves[1]["project_slug"] == "existing-project"
|
||||
assert result["project_id"] == PROJECT_ID
|
||||
@@ -81,6 +82,7 @@ def run():
|
||||
"scope": args["scope"],
|
||||
}
|
||||
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]["account_mode"] == "operator_account"
|
||||
assert "project_id" not in saves[0]
|
||||
|
||||
@@ -59,6 +59,7 @@ def test_account_login_requests_create_and_runtime_scopes():
|
||||
|
||||
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())
|
||||
assert "napseer.projects.write" in 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
|
||||
|
||||
|
||||
def test_successful_create_transitions_local_state_to_project_mode():
|
||||
def test_successful_create_preserves_general_account_mode():
|
||||
mod = load_module()
|
||||
configure_account_state(mod, mod.OPERATOR_ACCOUNT_OAUTH_SCOPE)
|
||||
saves = []
|
||||
@@ -102,7 +103,8 @@ def test_successful_create_transitions_local_state_to_project_mode():
|
||||
assert status == "created"
|
||||
assert 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]
|
||||
|
||||
|
||||
@@ -162,7 +164,7 @@ def test_anonymous_limits_are_actionable_without_exposing_response_details():
|
||||
if __name__ == "__main__":
|
||||
test_account_login_requests_create_and_runtime_scopes()
|
||||
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_anonymous_limits_are_actionable_without_exposing_response_details()
|
||||
print("ok: project create OAuth lifecycle passed")
|
||||
|
||||
@@ -169,7 +169,8 @@ def test_attach_forwards_and_verifies_repository_project_hint():
|
||||
assert oauth_calls[0]["project_id_hint"] == PROJECT_ID
|
||||
assert result["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__":
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user