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
|
`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,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",
|
||||||
|
|||||||
@@ -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 = 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")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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