From fac54f870564a6b14085fa8b94a7f60910f098af Mon Sep 17 00:00:00 2001 From: Alex Soffronow Pagonidis <237136924+alex-clickhouse@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:25:53 +0000 Subject: [PATCH 1/2] Add external auth mode that trusts the gateway actor header A hosted Nerve runs behind a gateway that signs people in and names the person behind each request in the X-Nerve-Actor-Context header. Local accounts, login and session tokens do not apply there. NERVE_AUTH_MODE selects the mode: local (default) or external. create_app() reads it once and pins it, so a configuration reload cannot change it. An unknown value stops the server and `nerve start`. In external mode: - require_auth and the WebSocket upgrade decode the header payload in one function, decode_actor_context(), and act as the person it names. The signature is not checked. A later change can replace that one function and keep its callers. - The actor_refs row is added on first sight, and the display name is written when it changes. An in-process map skips the write when the name is the same, so a normal request takes no write lock. A header that names the system actor gives 401. - Session tokens are refused on REST, /ws, the MCP endpoint and the worker-token route. System and MCP tokens give the system actor. - The account and setup routes are not registered, login answers 404, and GET /api/auth/status reports the mode without login state. - Startup creates no account, keeps the signing secret and deletes a setup token from local mode. nerve doctor does not warn about a missing local account. Local mode keeps its behavior. Its status responses add the mode field. Co-Authored-By: Claude Opus 5.5 --- docs/accounts.md | 45 +- docs/api.md | 15 +- docs/config.md | 19 + nerve/cli.py | 32 +- nerve/db/accounts.py | 24 + nerve/db/base.py | 3 + nerve/gateway/auth.py | 230 ++++- nerve/gateway/routes/__init__.py | 14 +- nerve/gateway/routes/auth.py | 36 +- nerve/gateway/server.py | 36 +- nerve/identity.py | 8 +- nerve/mcp_server/http.py | 3 +- nerve/migrate.py | 41 +- tests/conftest.py | 16 + tests/test_auth_secret_resolution.py | 6 +- tests/test_config_credential_migration.py | 2 +- tests/test_external_auth.py | 1092 +++++++++++++++++++++ tests/test_login_and_status.py | 12 +- tests/test_setup_wizard.py | 8 +- 19 files changed, 1592 insertions(+), 50 deletions(-) create mode 100644 tests/test_external_auth.py diff --git a/docs/accounts.md b/docs/accounts.md index edf88ec35..e7bd1fb3c 100644 --- a/docs/accounts.md +++ b/docs/accounts.md @@ -1,6 +1,8 @@ # Accounts and actor identity -Nerve uses local accounts for login and stable actor IDs for attribution. +Nerve uses local accounts for login and stable actor IDs for attribution. In +[external mode](#external-mode), a gateway names the person behind each request +and Nerve does not use local accounts. | Table | Purpose | |---|---| @@ -116,6 +118,47 @@ Disabling an account blocks its next HTTP or MCP request and any new WebSocket connection, and closes its open WebSocket connections. Autonomous work, including cron jobs and background agents, uses the system actor rather than a human account. +### External mode + +With `NERVE_AUTH_MODE=external`, a gateway in front of Nerve signs people in +and names the person behind each request. See +[Authentication mode](config.md#authentication-mode). + +| Credential | Acts as | +|---|---| +| `X-Nerve-Actor-Context` header | The human actor that the header names | +| Login session | Refused with `401` | +| Nerve CLI and internal API token | The system actor | +| Backend and external MCP token | The system actor | + +The header is a compact JWS. Nerve reads `principal_id` and +`profile.display_name` from its payload. It does not check the signature or +any other claim. `principal_id` must be a UUID, and Nerve uses its lowercase +form as the actor ID. A header that Nerve cannot read, or that names the system +actor, gives `401`. When the header is present, Nerve does not read +`Authorization`, the `nerve_token` cookie or `?token=`. The gateway also sends +the header on the WebSocket upgrade request. The connection keeps the actor of +that request until it closes. + +Nerve adds an `actor_refs` row of kind `human` when it sees a principal for the +first time, and writes the display name again when it changes. The row has no +`accounts` row. Nerve does not check the access of the person; the gateway +does. + +In external mode: + +- `POST /api/auth/login` returns `404`. The account routes (`/api/accounts`) + and the setup claim (`/api/setup/claim`) are not available. +- Startup creates no account and deletes a setup token from an earlier local + mode. It still makes the signing secret, because system and MCP tokens need + it. +- Accounts and history from an earlier local mode stay in `nerve.db`. + +> **Warning:** Nerve trusts the header. Use external mode only when the +> gateway is the only caller that can reach Nerve. A caller that can send the +> header can act as any person, also as the actor of an account from an earlier +> local mode. + ## Attribution Sessions store who created them in `created_by_actor_id`. User messages store diff --git a/docs/api.md b/docs/api.md index ffd7d09a2..9de1d034d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -27,6 +27,7 @@ Treat the returned token as opaque. | Valid credentials for a disabled account | `401` | | Setup is not complete (`login` is `setup`) | `409` | | Authentication is not ready | `503` | +| External mode (`mode` is `external`) | `404` | #### Authenticated requests @@ -36,17 +37,21 @@ or as `?token=` (for `` and downloads, which cannot set headers). Every request resolves its token to an account actor or the system principal. Unknown and disabled accounts fail with `401`; unavailable startup identity state fails with `503`. See [Accounts and identity](accounts.md) for token -types and legacy-session compatibility. +types and legacy-session compatibility. In external mode, the gateway names the +person in the `X-Nerve-Actor-Context` header and session tokens are refused; see +[External mode](accounts.md#external-mode). When a session is more than halfway to expiry, the response includes a refreshed token in `X-Nerve-Token`. Replace the current token with it. The header also upgrades sessions created before per-account login and is exposed through CORS. #### `GET /api/auth/status` -Return the required login fields. Authentication is not required. +Return the authentication mode and the required login fields. Authentication is +not required. ```json Response: { + "mode": "local", "auth_required": true, "login": "password" } @@ -54,6 +59,7 @@ Response: { | Field | Meaning | |---|---| +| `mode` | `local` or `external`; see [Authentication mode](config.md#authentication-mode) | | `login` | `setup`, `none`, `password`, or `username_password` | | `auth_required` | Compatibility field; equivalent to `login != "none"` | @@ -64,6 +70,11 @@ passwordless installation. The response does not expose usernames or the account count. Before authentication is ready, it returns the fail-closed `username_password` state. +In `external` mode, the gateway signs people in and local login is not +available. The response does not read local login state and always returns +`auth_required: true` and `login: "username_password"`. Use `mode` to decide +whether to show a login form. + #### `GET /api/auth/check` Verify current authentication. diff --git a/docs/config.md b/docs/config.md index 77c93b6db..0cc8094b0 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1752,6 +1752,25 @@ Nerve automatically discovers MCP servers from Claude Code's enabled plugins. An | `auth.password_hash` | string | - | Deprecated compatibility setting. Manage passwords from the Accounts page. If neither this setting nor the sole account has a password, anyone who can reach the gateway can act as the owner. See [Accounts and identity](accounts.md) | | `auth.jwt_secret` | string | - | JWT signing secret. When unset, Nerve generates one and stores it in `nerve.db`. Changing it requires a restart and signs users out. See [Accounts and identity](accounts.md) | +### Authentication mode + +The environment variable `NERVE_AUTH_MODE` sets how Nerve finds the person +behind a request. It is not a configuration key. Nerve reads it once, when the +server starts. A configuration reload does not change it; a restart does. + +| Value | Behavior | +|---|---| +| `local` | Default, also when the variable is unset or empty. Local accounts, login and session tokens. | +| `external` | A gateway in front of Nerve names the person behind each request in the `X-Nerve-Actor-Context` header. Local login, account management and the setup page are not available. See [External mode](accounts.md#external-mode). | + +Any other value stops `nerve start` and the server with an error. `nerve doctor` +shows the mode. + +> **Warning:** In external mode, Nerve trusts the `X-Nerve-Actor-Context` +> header and does not check its signature. A caller that can send a request to +> Nerve can act as any person. Use external mode only when the gateway is the +> only caller that can reach Nerve. + ## API Keys (config.local.yaml) | Key | Type | Description | diff --git a/nerve/cli.py b/nerve/cli.py index 4f770df6a..fec7ce162 100644 --- a/nerve/cli.py +++ b/nerve/cli.py @@ -326,6 +326,17 @@ def start(ctx: click.Context, foreground: bool) -> None: config_dir = Path(ctx.obj["config_dir"]) config = ctx.obj["config"] + # The server reads NERVE_AUTH_MODE when it starts and refuses an unknown + # value. Check it here too, so that this command shows the error and + # does not start a daemon that stops at once. + from nerve.config import ConfigError + from nerve.gateway.auth import auth_mode_from_env + + try: + auth_mode_from_env() + except ConfigError as e: + raise click.ClickException(str(e)) from e + # Migrate a legacy install to the workspace/config layout if needed # (idempotent, best-effort). Non-destructive — originals kept as *.migrated. # The gateway runs the identity bootstrap when it starts. @@ -1336,8 +1347,18 @@ def doctor_report(config, config_source: str = "", check_api: bool = False) -> s # the row), so judging by the row alone told operators that an *active* # password did nothing — and removing it on that advice would have opened # the instance. + # + # In external mode, the gateway names people and local accounts are not + # used, so the account checks do not apply. + from nerve.config import ConfigError from nerve.db.accounts import inspect_bootstrap_state, read_setup_required - from nerve.gateway.auth import source_authenticates + from nerve.gateway.auth import is_external_mode, source_authenticates + + try: + external = is_external_mode() + except ConfigError as e: + external = False + errors.append(f"[ERR] {e} The gateway does not start with this value.") configured = bool(config.auth.password_hash) identity_state = inspect_bootstrap_state(paths.db_path()) @@ -1346,7 +1367,12 @@ def doctor_report(config, config_source: str = "", check_api: bool = False) -> s source for source in (sources or []) if source_authenticates(source, configured_password=configured) ] - if sources is None: + if external: + lines.append( + "[OK] Auth mode: external. The gateway names the person behind " + "each request; local accounts are not used" + ) + elif sources is None: lines.append("[--] Accounts: nerve.db not created yet (first start will)") elif not sources: warnings.append("[WARN] No local account yet — the next start creates one") @@ -1367,7 +1393,7 @@ def doctor_report(config, config_source: str = "", check_api: bool = False) -> s lines.append( f"[OK] Accounts: {len(sources)} ({len(usable)} with a password)" ) - if configured and sources is not None: + if configured and sources is not None and not external: reading = [source for source in sources if source != "local"] if reading: lines.append( diff --git a/nerve/db/accounts.py b/nerve/db/accounts.py index bd9d0bb00..bc166d52e 100644 --- a/nerve/db/accounts.py +++ b/nerve/db/accounts.py @@ -182,6 +182,30 @@ async def update_actor_profile( ) return await self.get_actor_ref(actor_id) + async def upsert_external_actor( + self, actor_id: str, display_name: str | None, + ) -> None: + """Add a human actor that the gateway names, or write its new name. + + ``_external_actor_names`` holds the last name written for each ID in + this process. When the name is the same, no statement runs, so a + normal request does not take the SQLite write lock. + + Raises :class:`sqlite3.IntegrityError` for the system actor's ID: the + trigger ``system_actor_cannot_be_replaced`` aborts the insert. + """ + written = self._external_actor_names + if actor_id in written and written[actor_id] == display_name: + return + await self._write( + """INSERT INTO actor_refs (id, kind, display_name, created_at) + VALUES (?, 'human', ?, ?) + ON CONFLICT(id) DO UPDATE SET display_name = excluded.display_name + WHERE actor_refs.display_name IS NOT excluded.display_name""", + (actor_id, display_name, _now()), + ) + written[actor_id] = display_name + # -- accounts ------------------------------------------------------------ async def get_account(self, account_id: str) -> dict | None: diff --git a/nerve/db/base.py b/nerve/db/base.py index 0d66c3e9f..f0aca469b 100644 --- a/nerve/db/base.py +++ b/nerve/db/base.py @@ -341,6 +341,8 @@ def __init__(self, db_path: Path, workspace: Path | None = None): # a caller or test can tune them before connect() (e.g. busy_timeout=0). self._pragmas: dict[str, object] = dict(_DEFAULT_PRAGMAS) self._system_actor: Actor | None = None + # Actor ID to the last display name upsert_external_actor() wrote. + self._external_actor_names: dict[str, str | None] = {} # The state-file modes connect() found after its repair. The identity # bootstrap reads this before it stores a signing secret in the # database (nerve.migrate._refuse_insecure_secret_storage). @@ -513,6 +515,7 @@ async def close(self) -> None: await self._db.close() self._db = None self._system_actor = None + self._external_actor_names.clear() @property def db(self) -> aiosqlite.Connection: diff --git a/nerve/gateway/auth.py b/nerve/gateway/auth.py index c56cc6fef..0a3285e2f 100644 --- a/nerve/gateway/auth.py +++ b/nerve/gateway/auth.py @@ -2,21 +2,33 @@ Signing uses the secret pinned at startup. Verified tokens are resolved against the database on every request; a valid signature alone is not an identity. + +The authentication mode is pinned at startup from ``NERVE_AUTH_MODE``. In +``external`` mode, the gateway names the person behind a request in the +``X-Nerve-Actor-Context`` header (see :func:`decode_actor_context`), and local +logins and session tokens are not accepted. """ from __future__ import annotations +import base64 +import binascii +import json import logging +import os +import re +import sqlite3 from datetime import datetime, timedelta, timezone from typing import TYPE_CHECKING -from uuid import uuid4 +from uuid import UUID, uuid4 import bcrypt import jwt from fastapi import Depends, HTTPException, Request, WebSocket -from nerve.config import NerveConfig, get_config +from nerve.config import ConfigError, NerveConfig, get_config from nerve.identity import ( + ACTOR_KIND_HUMAN, Actor, ActorResolutionError, actor_for_account, @@ -185,6 +197,84 @@ def effective_jwt_secret(config: NerveConfig | None = None) -> str: return cfg.auth.jwt_secret or "" +# How this instance learns who makes a request. ``local``: local accounts and +# session tokens. ``external``: the gateway names the person in the actor +# context header. Only the environment sets the mode, and only at startup. +AUTH_MODE_ENV = "NERVE_AUTH_MODE" +AUTH_MODE_LOCAL = "local" +AUTH_MODE_EXTERNAL = "external" +AUTH_MODES = (AUTH_MODE_LOCAL, AUTH_MODE_EXTERNAL) + + +def parse_auth_mode(value: str | None) -> str: + """Parse an authentication mode, and refuse a mode that does not exist. + + Unset or blank is ``local``. An unknown value is an error and does not + fall back to ``local``: an operator who asks for one mode must not get a + different mode without notice. + """ + if value is None: + return AUTH_MODE_LOCAL + text = str(value).strip().lower() + if not text: + return AUTH_MODE_LOCAL + if text in AUTH_MODES: + return text + accepted = ", ".join(repr(mode) for mode in AUTH_MODES) + raise ConfigError( + f"{AUTH_MODE_ENV} must be one of {accepted}, got {value!r}. " + f"Unset {AUTH_MODE_ENV} to run in local mode." + ) + + +def auth_mode_from_env() -> str: + """Read and parse ``NERVE_AUTH_MODE``.""" + return parse_auth_mode(os.environ.get(AUTH_MODE_ENV)) + + +# Pinned once by create_app(). A configuration reload does not read it. +_pinned_auth_mode: str | None = None + + +def pin_auth_mode(mode: str) -> None: + """Pin the authentication mode for this process. + + A second pin of the same mode has no effect. A different mode raises + :class:`ConfigError`, because the mode changes only with a restart. + """ + global _pinned_auth_mode + if mode not in AUTH_MODES: + raise ValueError(f"unknown authentication mode {mode!r}") + if _pinned_auth_mode is not None and _pinned_auth_mode != mode: + raise ConfigError( + f"The authentication mode {_pinned_auth_mode!r} is already pinned for " + f"this process; {mode!r} was offered. Restart to change {AUTH_MODE_ENV}." + ) + _pinned_auth_mode = mode + + +def unpin_auth_mode() -> None: + """Clear the pinned authentication mode for tests.""" + global _pinned_auth_mode + _pinned_auth_mode = None + + +def auth_mode() -> str: + """Return the pinned mode, or the mode in the environment before startup. + + CLI commands such as ``nerve init`` and ``nerve doctor`` do not start the + gateway, so they read the environment. + """ + if _pinned_auth_mode is not None: + return _pinned_auth_mode + return auth_mode_from_env() + + +def is_external_mode() -> bool: + """Whether the gateway names the person behind each request.""" + return auth_mode() == AUTH_MODE_EXTERNAL + + def create_session_token( jwt_secret: str, account_id: str, expiry_hours: int | None = None, ) -> str: @@ -333,14 +423,27 @@ def identity_store() -> "Database | None": return getattr(deps, "db", None) +EXTERNAL_SESSION_DETAIL = ( + "Session tokens are not accepted in external mode; the gateway names the " + "person behind each request" +) + + async def resolve_actor_from_claims(store: "Database", claims: dict) -> Actor: - """Resolve verified token claims to their current actor.""" + """Resolve verified token claims to their current actor. + + In external mode, session tokens name no actor: only system and MCP + tokens are accepted, and they give the system actor. + """ if claims.get("aud") == MCP_AUDIENCE: return store.system_actor token_type = claims.get(TOKEN_TYPE_CLAIM) if token_type == TOKEN_TYPE_SYSTEM: return store.system_actor + is_session = token_type == TOKEN_TYPE_SESSION or is_legacy_session_token(claims) + if is_session and is_external_mode(): + raise ActorResolutionError(EXTERNAL_SESSION_DETAIL) if token_type == TOKEN_TYPE_SESSION: return await actor_for_account(store, claims.get("sub")) if is_legacy_session_token(claims): @@ -349,12 +452,109 @@ async def resolve_actor_from_claims(store: "Database", claims: dict) -> Actor: raise ActorResolutionError("This credential names no actor") +# In external mode, the gateway sends the person behind a request in this +# header, as a compact JWS. +ACTOR_CONTEXT_HEADER = "X-Nerve-Actor-Context" + +_BASE64URL = re.compile(r"[A-Za-z0-9_-]*") + + +class ActorContextError(ValueError): + """The actor context header cannot be read.""" + + +def decode_actor_context(value: str) -> tuple[str, str | None]: + """Return the principal ID and display name in an actor context header. + + The value is a compact JWS. This function decodes the payload and does + not check the signature, so it trusts every caller that can reach Nerve. + External mode needs the gateway to be the only caller. Every reader of + the header calls this function. + + The principal ID is returned as a canonical lower-case UUID string. + Raises :class:`ActorContextError` when the value cannot be read. + """ + parts = value.split(".") + if len(parts) != 3: + raise ActorContextError("The actor context is not a compact JWS") + segment = parts[1] + if not _BASE64URL.fullmatch(segment): + raise ActorContextError("The actor context payload is not base64url") + try: + claims = json.loads(base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4))) + except (binascii.Error, ValueError, RecursionError) as e: + # RecursionError: JSON that is nested too deeply to parse. + raise ActorContextError("The actor context payload is not base64url JSON") from e + if not isinstance(claims, dict): + raise ActorContextError("The actor context payload is not a JSON object") + + principal = claims.get("principal_id") + if not isinstance(principal, str): + raise ActorContextError("The actor context names no principal") + try: + principal_id = str(UUID(principal)) + except ValueError as e: + raise ActorContextError("The actor context principal is not a UUID") from e + + profile = claims.get("profile") + if profile is None: + return principal_id, None + if not isinstance(profile, dict): + raise ActorContextError("The actor context profile is not a JSON object") + display_name = profile.get("display_name") + if display_name is not None and not isinstance(display_name, str): + raise ActorContextError("The actor context display name is not a string") + return principal_id, display_name + + +async def resolve_external_actor(store: "Database", value: str) -> Actor: + """Resolve an actor context header to a human actor. + + Adds the ``actor_refs`` row on first sight and writes a changed display + name, before the request can write anything that refers to the actor. + The actor has no local account. + """ + try: + actor_id, display_name = decode_actor_context(value) + except ActorContextError as e: + raise ActorResolutionError(str(e)) from e + try: + await store.upsert_external_actor(actor_id, display_name) + except sqlite3.IntegrityError as e: + # A schema trigger refuses the system actor's ID. + raise ActorResolutionError( + "The actor context names an actor that cannot act as a person" + ) from e + return Actor( + actor_id=actor_id, + kind=ACTOR_KIND_HUMAN, + account_id=None, + display_name=display_name, + ) + + async def require_auth(request: Request) -> Actor: - """Authenticate an HTTP request and return its request-local actor.""" + """Authenticate an HTTP request and return its request-local actor. + + In external mode, a request with the actor context header acts as the + person it names, and its tokens and cookies are ignored. A request + without the header can authenticate only with a system or MCP token. + """ secret = effective_jwt_secret(get_config()) if not secret: raise HTTPException(status_code=503, detail=NO_SECRET_DETAIL) + external = is_external_mode() + context = request.headers.get(ACTOR_CONTEXT_HEADER) if external else None + if context is not None: + store = identity_store() + if store is None: + raise HTTPException(status_code=503, detail=NO_IDENTITY_DETAIL) + try: + return await resolve_external_actor(store, context) + except ActorResolutionError as e: + raise HTTPException(status_code=401, detail=str(e)) from e + token = get_token_from_request(request) payload = decode_token(token, secret) @@ -366,6 +566,10 @@ async def require_auth(request: Request) -> Actor: except ActorResolutionError as e: raise HTTPException(status_code=401, detail=str(e)) from e + if external: + # External mode accepts no session tokens, so it has none to refresh. + return actor + # Middleware emits this without changing route response models. if is_legacy_session_token(payload) and actor.account_id: request.state.refreshed_token = create_session_token(secret, actor.account_id) @@ -377,11 +581,27 @@ async def require_auth(request: Request) -> Actor: async def authenticate_websocket(websocket: WebSocket) -> Actor | None: - """Resolve a WebSocket actor when the connection is admitted.""" + """Resolve a WebSocket actor when the connection is admitted. + + In external mode, the gateway signs the upgrade request, so the actor + context header names the person, as for HTTP requests. + """ secret = effective_jwt_secret(get_config()) if not secret: return None # fail closed, as require_auth does + if is_external_mode(): + context = websocket.headers.get(ACTOR_CONTEXT_HEADER) + if context is not None: + store = identity_store() + if store is None: + return None + try: + return await resolve_external_actor(store, context) + except ActorResolutionError as e: + logger.info("WebSocket refused: %s", e) + return None + token = websocket.query_params.get("token") or websocket.cookies.get("nerve_token") if not token: return None diff --git a/nerve/gateway/routes/__init__.py b/nerve/gateway/routes/__init__.py index bc4d2abaa..3840e7975 100644 --- a/nerve/gateway/routes/__init__.py +++ b/nerve/gateway/routes/__init__.py @@ -10,6 +10,7 @@ from fastapi import APIRouter +from nerve.gateway.auth import is_external_mode from nerve.gateway.routes._deps import ( get_deps, init_deps, @@ -51,12 +52,19 @@ def register_all_routes() -> APIRouter: - """Assemble and return the combined API router.""" + """Assemble and return the combined API router. + + In external mode, the gateway manages people, so the local account and + setup routes are not registered. + """ + local_accounts = not is_external_mode() router = APIRouter() router.include_router(auth.router) - router.include_router(accounts.router) + if local_accounts: + router.include_router(accounts.router) router.include_router(actors.router) - router.include_router(setup.router) + if local_accounts: + router.include_router(setup.router) router.include_router(sessions.router) router.include_router(tasks.router) router.include_router(plans.router) diff --git a/nerve/gateway/routes/auth.py b/nerve/gateway/routes/auth.py index a4147e246..839e3b40a 100644 --- a/nerve/gateway/routes/auth.py +++ b/nerve/gateway/routes/auth.py @@ -12,13 +12,16 @@ from nerve.config import get_config from nerve.gateway.auth import ( + AUTH_MODE_EXTERNAL, BCRYPT_COST, NO_IDENTITY_DETAIL, + auth_mode, bcrypt_cost, create_session_token, effective_jwt_secret, hash_password, identity_store, + is_external_mode, needs_rehash, password_length_problem, require_auth, @@ -190,7 +193,21 @@ class LoginResponse(BaseModel): token: str -@router.post("/api/auth/login", response_model=LoginResponse) +async def _local_login_only() -> None: + """Answer 404 in external mode, where the gateway signs people in. + + FastAPI runs this dependency before it validates the login fields, so a + request with missing or wrong fields also gets 404. + """ + if is_external_mode(): + raise HTTPException(status_code=404, detail="Not Found") + + +@router.post( + "/api/auth/login", + response_model=LoginResponse, + dependencies=[Depends(_local_login_only)], +) async def login(req: LoginRequest): """Authenticate a local account and return a session token.""" config = get_config() @@ -257,14 +274,22 @@ async def login(req: LoginRequest): async def auth_status(): """Describe the login form without identifying accounts. - ``auth_required`` is the legacy spelling of ``login != 'none'``. Missing - startup state fails closed to username and password. ``setup`` means that - only the setup-token claim is permitted. + ``mode`` is the authentication mode. ``auth_required`` is the legacy + spelling of ``login != 'none'``. Missing startup state fails closed to + username and password. ``setup`` means that only the setup-token claim is + permitted. + + In external mode, the gateway signs people in. The response does not read + local login state, and ``login`` keeps the fail-closed value. """ + mode = auth_mode() + if mode == AUTH_MODE_EXTERNAL: + return {"mode": mode, **_UNKNOWN_STATUS} + config = get_config() store = identity_store() if store is None or not effective_jwt_secret(config): - return dict(_UNKNOWN_STATUS) + return {"mode": mode, **_UNKNOWN_STATUS} state = await store.login_state() if setup_required(state, config): @@ -277,6 +302,7 @@ async def auth_status(): login_kind = LOGIN_USERNAME_PASSWORD return { + "mode": mode, "auth_required": login_kind != LOGIN_NONE, "login": login_kind, } diff --git a/nerve/gateway/server.py b/nerve/gateway/server.py index 218ec2750..db704bb13 100644 --- a/nerve/gateway/server.py +++ b/nerve/gateway/server.py @@ -28,9 +28,14 @@ from nerve.config import NerveConfig, get_config from nerve.db import Database, init_db, close_db from nerve.gateway.auth import ( + ACTOR_CONTEXT_HEADER, + AUTH_MODE_EXTERNAL, SESSION_TOKEN_HEADER, + auth_mode_from_env, authenticate_websocket, identity_store, + is_external_mode, + pin_auth_mode, ) from nerve.identity import Actor, ActorResolutionError, actor_for_account from nerve.gateway.routes import ( @@ -139,9 +144,15 @@ async def _accept_websocket(websocket: WebSocket) -> WebSocketConnection | None: async def _account_enabled(actor: Actor) -> bool: - """Whether the actor may still use a socket. The system principal may.""" + """Whether the actor may still use a socket. The system principal may. + + In external mode, a person that the gateway names has no local account, + and the gateway decides access. + """ if actor.is_system: return True + if is_external_mode() and actor.account_id is None: + return True store = identity_store() if store is None or not actor.account_id: return False @@ -351,12 +362,16 @@ async def _unwind_startup() -> None: logger.info("Identity bootstrap: %s", action) # Create the setup token before serving. Never log it: `nerve status` - # reads it from the database. + # reads it from the database. External mode has no local setup, so a + # token left from local mode is deleted. from nerve import setup_token await setup_token.ensure_setup_token( db, - unclaimed=await setup_token.instance_is_unclaimed(db, config), + unclaimed=( + not is_external_mode() + and await setup_token.instance_is_unclaimed(db, config) + ), ) # Start CLIProxyAPI if enabled (must be up before engine/memU initializes) @@ -932,7 +947,20 @@ async def _periodic_notify_maintenance(): def create_app() -> FastAPI: - """Create and configure the FastAPI application.""" + """Create and configure the FastAPI application. + + Reads ``NERVE_AUTH_MODE`` once and pins it before any route is + registered. An unknown value raises :class:`ConfigError` and stops + startup. + """ + pin_auth_mode(auth_mode_from_env()) + if is_external_mode(): + logger.warning( + "Authentication mode is %s: Nerve trusts the %s header without a " + "signature check. Only the gateway must be able to reach this server.", + AUTH_MODE_EXTERNAL, ACTOR_CONTEXT_HEADER, + ) + app = FastAPI( title="Nerve", description="Personal AI Assistant", diff --git a/nerve/identity.py b/nerve/identity.py index 6c9522433..118fb2a36 100644 --- a/nerve/identity.py +++ b/nerve/identity.py @@ -27,8 +27,9 @@ if TYPE_CHECKING: # pragma: no cover - typing only, never imported at runtime from nerve.db.accounts import AccountStore -# ``actor_refs.kind``. A human is a person with a local account; the system -# principal is the identity used for the agent's autonomous work. +# ``actor_refs.kind``. A human is a person: the owner of a local account, or, +# in external mode, a person that the gateway names. The system principal is +# the identity used for the agent's autonomous work. ACTOR_KIND_HUMAN = "human" ACTOR_KIND_SYSTEM = "system" ACTOR_KINDS = (ACTOR_KIND_HUMAN, ACTOR_KIND_SYSTEM) @@ -45,7 +46,8 @@ class Actor: actor_id: str kind: str # The local login behind a human actor. ``None`` for the system principal, - # which has no account and cannot log in. + # which has no account and cannot log in, and for a person that the + # gateway names in external mode. account_id: str | None = None # Presentation snapshot, never an identity or authorization key. display_name: str | None = None diff --git a/nerve/mcp_server/http.py b/nerve/mcp_server/http.py index 0343dddfd..0adbd91f2 100644 --- a/nerve/mcp_server/http.py +++ b/nerve/mcp_server/http.py @@ -288,7 +288,8 @@ async def _mcp_asgi_app(scope: Scope, receive: Receive, send: Send) -> None: # credentials are the agent acting on its own behalf, so they resolve # to the system principal; a person's own session token resolves to # them — and is refused here once their account is disabled, which a - # signature check alone would never notice. + # signature check alone would never notice. In external mode, every + # session token is refused here. store = identity_store() if store is None: await _send_status(send, 503, "MCP server is starting up") diff --git a/nerve/migrate.py b/nerve/migrate.py index cc8157ec2..03d1176e5 100644 --- a/nerve/migrate.py +++ b/nerve/migrate.py @@ -1408,8 +1408,18 @@ async def bootstrap_identity( bootstrap. ``local`` accounts are not changed. Results go to ``report``. With ``dry_run``, nothing is written. + + In external mode, the gateway names people, so no account is created or + changed. The signing secret is still made, because system and MCP tokens + need it. """ + from nerve.gateway.auth import is_external_mode + report = MigrationReport(dry_run=dry_run) if report is None else report + if is_external_mode(): + await ensure_jwt_secret(db, config, report=report, dry_run=dry_run) + return report + source = _credential_source_for(config) # Include accounts a dry run would create with the transitional source so # credential migration can report what it would do with them. @@ -1556,10 +1566,26 @@ def _preview_identity( def _inspect_identity(config: NerveConfig, db_path: Path, report: MigrationReport) -> None: """Report what :func:`bootstrap_identity` would do, reading nerve.db read-only.""" from nerve.db.accounts import inspect_bootstrap_state + from nerve.gateway.auth import is_external_mode - source = _credential_source_for(config) state = inspect_bootstrap_state(db_path) sources, stored = state if state is not None else ([], False) + if not is_external_mode(): + _inspect_accounts(config, sources, report) + if config.auth.jwt_secret: + if stored: + report.retired_stored_secret = True + report.identity_actions.append(_retire_action(dry_run=True)) + elif not stored: + report.generated_jwt_secret = True + report.identity_actions.append(_secret_action(dry_run=True)) + + +def _inspect_accounts( + config: NerveConfig, sources: list[str], report: MigrationReport, +) -> None: + """Report the account steps of :func:`bootstrap_identity`.""" + source = _credential_source_for(config) if not sources: # no database / pre-v047 schema, or zero accounts report.bootstrapped_account = True report.identity_actions.append(_account_action(source, dry_run=True)) @@ -1588,13 +1614,6 @@ def _inspect_identity(config: NerveConfig, db_path: Path, report: MigrationRepor current in ("config", "none") for current in settled ): _retire_config_password(config, report, dry_run=True) - if config.auth.jwt_secret: - if stored: - report.retired_stored_secret = True - report.identity_actions.append(_retire_action(dry_run=True)) - elif not stored: - report.generated_jwt_secret = True - report.identity_actions.append(_secret_action(dry_run=True)) def bootstrap_identity_sync( @@ -1613,6 +1632,9 @@ def bootstrap_identity_sync( ``passwordless`` records the operator's choice of a passwordless installation, which completes setup. It has no effect on an account that has a password. + + In external mode, no account is created and ``display_name`` and + ``passwordless`` have no effect. Only the signing secret is made. """ try: asyncio.get_running_loop() @@ -1640,12 +1662,13 @@ async def _bootstrap_with_own_connection( passwordless: bool = False, ) -> None: from nerve.db import Database + from nerve.gateway.auth import is_external_mode db = Database(db_path, workspace=config.workspace) await db.connect() try: await bootstrap_identity(db, config, report=report, display_name=display_name) - if passwordless: + if passwordless and not is_external_mode(): await _confirm_passwordless(db, config, report) finally: await db.close() diff --git a/tests/conftest.py b/tests/conftest.py index c50b95f2e..ab4c96e08 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -95,6 +95,22 @@ def _unpin_jwt_secret(): unpin_jwt_secret() +@pytest.fixture(autouse=True) +def _unpin_auth_mode(monkeypatch): + """Clear the authentication mode before and after each test. + + ``create_app()`` pins the mode once per process, and before a pin the mode + comes from ``NERVE_AUTH_MODE``. Each test starts in local mode, whatever + the shell sets, and a test that pins a mode does not leave it in force. + """ + from nerve.gateway.auth import AUTH_MODE_ENV, unpin_auth_mode + + monkeypatch.delenv(AUTH_MODE_ENV, raising=False) + unpin_auth_mode() + yield + unpin_auth_mode() + + @pytest.fixture(scope="session", autouse=True) def _deterministic_umask(): """Run the suite under umask 022, whatever the developer's shell has. diff --git a/tests/test_auth_secret_resolution.py b/tests/test_auth_secret_resolution.py index fda5c126d..cce9e22a5 100644 --- a/tests/test_auth_secret_resolution.py +++ b/tests/test_auth_secret_resolution.py @@ -181,7 +181,7 @@ def test_password_checked_and_token_signed_with_the_configured_secret(self, clie assert claims["sub"] == client.account_id assert claims[TOKEN_TYPE_CLAIM] == TOKEN_TYPE_SESSION assert client.get("/api/auth/status").json() == { - "auth_required": True, "login": "password", + "mode": "local", "auth_required": True, "login": "password", } def test_password_checked_and_token_signed_with_the_generated_secret(self, client, config): @@ -201,7 +201,7 @@ def test_password_checked_and_token_signed_with_the_generated_secret(self, clien with pytest.raises(jwt.InvalidSignatureError): _claims(token, "dev-secret") assert client.get("/api/auth/status").json() == { - "auth_required": True, "login": "password", + "mode": "local", "auth_required": True, "login": "password", } def test_passwordless_admits_any_password_with_a_real_secret(self, client, config): @@ -210,7 +210,7 @@ def test_passwordless_admits_any_password_with_a_real_secret(self, client, confi pin_jwt_secret(_GENERATED) assert client.portal.call(client.db.complete_passwordless_setup) assert client.get("/api/auth/status").json() == { - "auth_required": False, "login": "none", + "mode": "local", "auth_required": False, "login": "none", } res = client.post("/api/auth/login", json={"password": "anything at all"}) assert res.status_code == 200 diff --git a/tests/test_config_credential_migration.py b/tests/test_config_credential_migration.py index 3014d023f..583154d25 100644 --- a/tests/test_config_credential_migration.py +++ b/tests/test_config_credential_migration.py @@ -404,7 +404,7 @@ async def test_login_after_the_migration( assert good.status_code == 200, good.text assert bad.status_code == 401 - assert status.json() == {"auth_required": True, "login": "password"} + assert status.json() == {"mode": "local", "auth_required": True, "login": "password"} # --------------------------------------------------------------------------- # diff --git a/tests/test_external_auth.py b/tests/test_external_auth.py new file mode 100644 index 000000000..e2b318104 --- /dev/null +++ b/tests/test_external_auth.py @@ -0,0 +1,1092 @@ +"""External authentication mode (``NERVE_AUTH_MODE=external``). + +The gateway names the person behind each request in the +``X-Nerve-Actor-Context`` header. Nerve decodes the payload, ignores the +signature, adds or renames the actor row, and acts as that person. Local +logins, local accounts and session tokens are not available. System and MCP +tokens still give the system actor. + +The headers here are unsigned: ``b64url(header) + "." + b64url(payload) + ".x"``. +""" + +from __future__ import annotations + +import asyncio +import base64 +import contextlib +import functools +import json +import time +from types import SimpleNamespace + +import httpx +import jwt +import pytest +import pytest_asyncio +from fastapi import Depends, FastAPI, Request +from fastapi.testclient import TestClient +from starlette.websockets import WebSocketDisconnect + +from nerve import paths +from nerve.agent.engine import AgentEngine +from nerve.agent.streaming import broadcaster +from nerve.config import ConfigError, NerveConfig, load_config, set_config +from nerve.db import Database +from nerve.db.accounts import inspect_bootstrap_state, read_instance_secret +from nerve.gateway import server +from nerve.gateway.auth import ( + ACTOR_CONTEXT_HEADER, + AUTH_MODE_ENV, + AUTH_MODE_EXTERNAL, + AUTH_MODE_LOCAL, + EXTERNAL_SESSION_DETAIL, + JWT_ALGORITHM, + SESSION_TOKEN_HEADER, + ActorContextError, + auth_mode, + create_external_mcp_token, + create_mcp_session_token, + create_session_token, + create_system_token, + decode_actor_context, + parse_auth_mode, + pin_auth_mode, + pinned_jwt_secret, + require_auth, +) +from nerve.gateway.routes import init_deps, register_all_routes +from nerve.identity import Actor +from nerve.migrate import MigrationReport, bootstrap_identity + +_SECRET = "test-secret-for-external-auth-padded-32b" +ALICE = "6f1c2a8e-3b4d-4e5f-8a9b-0c1d2e3f4a5b" +BOB = "0a9b8c7d-6e5f-4a3b-9c2d-1e0f9a8b7c6d" + + +# --------------------------------------------------------------------------- # +# Helpers # +# --------------------------------------------------------------------------- # + + +def _b64url(value: object) -> str: + raw = value if isinstance(value, bytes) else json.dumps(value).encode() + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + +def _context(principal_id: object, display_name: str | None = None, **claims) -> str: + """An unsigned actor context header value.""" + payload = {"principal_id": principal_id, **claims} + if display_name is not None: + payload["profile"] = {"display_name": display_name} + return _b64url({"alg": "ES256", "typ": "JWT"}) + "." + _b64url(payload) + ".x" + + +def _as(principal_id: str, display_name: str | None = None) -> dict: + return {ACTOR_CONTEXT_HEADER: _context(principal_id, display_name)} + + +def _bearer(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +def _client(app: FastAPI) -> httpx.AsyncClient: + return httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://nerve-test", + ) + + +def _whoami_app() -> FastAPI: + """The real dependency and the real slide middleware, as in the gateway.""" + app = FastAPI() + + @app.middleware("http") + async def _slide_session_token(request: Request, call_next): + response = await call_next(request) + token = getattr(request.state, "refreshed_token", None) + if token: + response.headers[SESSION_TOKEN_HEADER] = token + return response + + @app.get("/api/whoami") + async def whoami(actor: Actor = Depends(require_auth)): + return { + "actor_id": actor.actor_id, + "kind": actor.kind, + "account_id": actor.account_id, + "display_name": actor.display_name, + } + + return app + + +def _route_paths(router) -> set[str]: + """Every route path, including the routes of included routers.""" + found: set[str] = set() + for route in router.routes: + included = getattr(route, "original_router", None) + if included is not None: + found |= _route_paths(included) + elif getattr(route, "path", ""): + found.add(route.path) + return found + + +def _legacy_token() -> str: + from datetime import datetime, timedelta, timezone + + now = datetime.now(timezone.utc) + return jwt.encode( + {"iat": now, "exp": now + timedelta(hours=1), "sub": "user"}, + _SECRET, + algorithm=JWT_ALGORITHM, + ) + + +def _config(tmp_path) -> NerveConfig: + return NerveConfig.from_dict({ + "workspace": str(tmp_path / "ws"), + "codex": {"home_dir": str(tmp_path / "codex-home")}, + "auth": {"jwt_secret": _SECRET}, + }) + + +@contextlib.asynccontextmanager +async def _no_lifespan(_app): + yield + + +# --------------------------------------------------------------------------- # +# Fixtures # +# --------------------------------------------------------------------------- # + + +@pytest.fixture +def external(monkeypatch): + """Run the test in external mode, as ``create_app()`` would pin it.""" + monkeypatch.setenv(AUTH_MODE_ENV, AUTH_MODE_EXTERNAL) + pin_auth_mode(AUTH_MODE_EXTERNAL) + + +class _Install: + """One external-mode instance after its identity bootstrap.""" + + def __init__(self, db: Database, engine: AgentEngine, report: MigrationReport): + self.db = db + self.engine = engine + self.report = report + + @property + def system_actor_id(self) -> str: + return self.db.system_actor_id + + async def humans(self) -> list[dict]: + return await self.db.list_actor_refs(kind="human") + + async def creator_of(self, session_id: str) -> str | None: + return (await self.db.get_session(session_id))["created_by_actor_id"] + + async def said_in(self, session_id: str) -> list[tuple[str, str | None]]: + return [ + (row["content"], row["actor_id"]) + for row in await self.db.get_messages(session_id) + if row["role"] == "user" + ] + + async def add_leftover_account(self) -> str: + """An account left from local mode.""" + account = await self.db._bootstrap_first_account(credential_source="none") + assert account.created + return account.account_id + + +@pytest_asyncio.fixture +async def install(tmp_path, external, wire_identity_store): + config = _config(tmp_path) + set_config(config) + database = Database(tmp_path / "nerve.db") + await database.connect() + try: + report = await bootstrap_identity(database, config) + wire_identity_store(database) + engine = AgentEngine(config, database) + init_deps(engine, database) + yield _Install(database, engine, report) + finally: + await database.close() + set_config(NerveConfig()) + + +# --------------------------------------------------------------------------- # +# The mode # +# --------------------------------------------------------------------------- # + + +class TestMode: + @pytest.mark.parametrize("value", [None, "", " "]) + def test_unset_or_blank_is_local(self, value): + assert parse_auth_mode(value) == AUTH_MODE_LOCAL + + @pytest.mark.parametrize( + ("value", "mode"), + [("local", "local"), ("LOCAL", "local"), (" External ", "external")], + ) + def test_known_modes_are_accepted_case_insensitively(self, value, mode): + assert parse_auth_mode(value) == mode + + @pytest.mark.parametrize("value", ["hosted", "simple", "none", "externally"]) + def test_anything_else_is_refused_with_the_accepted_values_named(self, value): + with pytest.raises(ConfigError) as ei: + parse_auth_mode(value) + message = str(ei.value) + assert AUTH_MODE_ENV in message + assert "'local'" in message and "'external'" in message + assert repr(value) in message + + def test_an_unknown_value_stops_startup(self, monkeypatch): + monkeypatch.setenv(AUTH_MODE_ENV, "hosted") + with pytest.raises(ConfigError, match=AUTH_MODE_ENV): + server.create_app() + + def test_startup_pins_the_mode_from_the_environment(self, monkeypatch): + monkeypatch.setenv(AUTH_MODE_ENV, "external") + server.create_app() + monkeypatch.delenv(AUTH_MODE_ENV) + assert auth_mode() == AUTH_MODE_EXTERNAL + + def test_before_startup_the_environment_decides(self, monkeypatch): + assert auth_mode() == AUTH_MODE_LOCAL + monkeypatch.setenv(AUTH_MODE_ENV, "external") + assert auth_mode() == AUTH_MODE_EXTERNAL + + def test_a_pin_cannot_change_to_another_mode(self): + pin_auth_mode(AUTH_MODE_EXTERNAL) + pin_auth_mode(AUTH_MODE_EXTERNAL) + with pytest.raises(ConfigError, match="already pinned"): + pin_auth_mode(AUTH_MODE_LOCAL) + assert auth_mode() == AUTH_MODE_EXTERNAL + + @pytest.mark.asyncio + async def test_a_reload_does_not_change_the_mode(self, tmp_path, monkeypatch): + from nerve.config_reload import reload_all + + config_dir, workspace = tmp_path / "cfg", tmp_path / "ws" + config_dir.mkdir() + (workspace / "config").mkdir(parents=True) + (config_dir / "config.yaml").write_text( + f"workspace: {workspace}\n", encoding="utf-8", + ) + set_config(load_config(config_dir)) + try: + monkeypatch.setenv(AUTH_MODE_ENV, "external") + server.create_app() + + monkeypatch.setenv(AUTH_MODE_ENV, "local") + (config_dir / "config.yaml").write_text( + f"workspace: {workspace}\ntimezone: UTC\n", encoding="utf-8", + ) + summary = await reload_all(None, None, config_dir) + + assert summary["config"] == "reloaded" + assert auth_mode() == AUTH_MODE_EXTERNAL + routes = _route_paths(register_all_routes()) + assert "/api/actors" in routes and "/api/accounts" not in routes + finally: + set_config(NerveConfig()) + + +# --------------------------------------------------------------------------- # +# Reading the header # +# --------------------------------------------------------------------------- # + + +class TestDecodeActorContext: + def test_it_reads_the_principal_and_the_display_name(self): + assert decode_actor_context(_context(ALICE, "Alice")) == (ALICE, "Alice") + + def test_an_upper_case_uuid_gives_the_canonical_string(self): + assert decode_actor_context(_context(ALICE.upper(), "Alice")) == (ALICE, "Alice") + + def test_no_profile_or_no_name_gives_no_display_name(self): + assert decode_actor_context(_context(ALICE)) == (ALICE, None) + assert decode_actor_context( + _context(ALICE, profile={"display_name": None}), + ) == (ALICE, None) + assert decode_actor_context(_context(ALICE, profile={})) == (ALICE, None) + + def test_the_signature_and_other_claims_are_ignored(self): + value = _context( + ALICE, "Alice", tenant_id="t-1", agent_id="a-1", aud="someone", exp=1, + ) + header, payload, _signature = value.split(".") + assert decode_actor_context(f"{header}.{payload}.") == (ALICE, "Alice") + assert decode_actor_context(f"{header}.{payload}.anything") == (ALICE, "Alice") + + @pytest.mark.parametrize( + "value", + [ + "", + "abc", + "a.b", + "a.b.c.d", + "h.!!!.x", + "h." + _b64url(b"not json") + ".x", + "h." + _b64url(b"\xff\xfe\xfd") + ".x", + "h." + _b64url(b"[" * 50_000) + ".x", + "h." + _b64url([ALICE]) + ".x", + "h." + _b64url({"sub": ALICE}) + ".x", + _context(42), + _context("alice"), + _context(ALICE[:-1]), + _context(ALICE, profile="Alice"), + _context(ALICE, profile={"display_name": 42}), + ], + ids=[ + "empty", "one-part", "two-parts", "four-parts", "not-base64url", + "not-json", "not-utf8", "nested-too-deep", "not-an-object", + "no-principal", "number-principal", + "name-principal", "short-uuid", "profile-not-object", "name-not-string", + ], + ) + def test_a_malformed_value_is_refused(self, value): + with pytest.raises(ActorContextError): + decode_actor_context(value) + + +# --------------------------------------------------------------------------- # +# HTTP requests # +# --------------------------------------------------------------------------- # + + +@pytest.mark.asyncio +class TestHttpAuthentication: + async def test_the_header_acts_as_the_person_it_names(self, install): + async with _client(_whoami_app()) as client: + res = await client.get("/api/whoami", headers=_as(ALICE, "Alice")) + assert res.status_code == 200 + assert res.json() == { + "actor_id": ALICE, "kind": "human", "account_id": None, + "display_name": "Alice", + } + assert SESSION_TOKEN_HEADER not in res.headers + + async def test_no_header_and_no_token_is_refused(self, install): + async with _client(_whoami_app()) as client: + res = await client.get("/api/whoami") + assert res.status_code == 401 + + @pytest.mark.parametrize( + "value", ["", "abc", "a.b.c.d", _context("alice"), _context(42)], + ids=["empty", "one-part", "four-parts", "not-a-uuid", "number"], + ) + async def test_a_malformed_header_is_refused(self, install, value): + async with _client(_whoami_app()) as client: + res = await client.get( + "/api/whoami", headers={ACTOR_CONTEXT_HEADER: value}, + ) + assert res.status_code == 401 + assert await install.humans() == [] + + async def test_an_upper_case_uuid_is_the_same_actor(self, install): + async with _client(_whoami_app()) as client: + lower = await client.get("/api/whoami", headers=_as(ALICE, "Alice")) + upper = await client.get("/api/whoami", headers=_as(ALICE.upper(), "Alice")) + assert lower.json()["actor_id"] == upper.json()["actor_id"] == ALICE + assert [row["id"] for row in await install.humans()] == [ALICE] + + async def test_the_header_wins_over_every_token(self, install): + """With the header present, Authorization, the cookie and ``?token=`` + are not read, even when they carry a valid system token.""" + system = create_system_token(_SECRET) + async with _client(_whoami_app()) as client: + client.cookies.set("nerve_token", system) + res = await client.get( + f"/api/whoami?token={system}", + headers={**_bearer(system), **_as(ALICE, "Alice")}, + ) + assert res.status_code == 200 + assert res.json()["actor_id"] == ALICE + assert res.json()["kind"] == "human" + + async def test_a_malformed_header_is_not_rescued_by_a_token(self, install): + async with _client(_whoami_app()) as client: + res = await client.get( + "/api/whoami", + headers={ + **_bearer(create_system_token(_SECRET)), + ACTOR_CONTEXT_HEADER: "abc", + }, + ) + assert res.status_code == 401 + + async def test_who_am_i_reports_a_person_without_an_account(self, install): + async with _client(server.create_app()) as client: + res = await client.get("/api/auth/me", headers=_as(ALICE, "Alice")) + check = await client.get("/api/auth/check", headers=_as(ALICE, "Alice")) + assert res.status_code == 200 + assert res.json() == { + "actor": { + "id": ALICE, "kind": "human", "display_name": "Alice", + "username": None, + }, + "account": None, + } + assert check.json() == {"authenticated": True} + + async def test_the_header_means_nothing_in_local_mode( + self, tmp_path, open_identity_db, wire_identity_store, + ): + """Without external mode the header is not read at all.""" + set_config(_config(tmp_path)) + try: + from nerve.gateway.auth import pin_jwt_secret + + pin_jwt_secret(_SECRET) + database, _identity = await open_identity_db(tmp_path / "nerve.db") + wire_identity_store(database) + try: + async with _client(_whoami_app()) as client: + res = await client.get("/api/whoami", headers=_as(ALICE, "Alice")) + assert res.status_code == 401 + assert await database.get_actor_ref(ALICE) is None + finally: + await database.close() + finally: + set_config(NerveConfig()) + + +# --------------------------------------------------------------------------- # +# The actor row # +# --------------------------------------------------------------------------- # + + +@pytest.mark.asyncio +class TestActorRows: + async def test_first_sight_adds_the_row(self, install): + assert await install.db.get_actor_ref(ALICE) is None + async with _client(_whoami_app()) as client: + assert ( + await client.get("/api/whoami", headers=_as(ALICE, "Alice")) + ).status_code == 200 + row = await install.db.get_actor_ref(ALICE) + assert (row["kind"], row["display_name"], row["username"]) == ( + "human", "Alice", None, + ) + assert await install.db.count_accounts() == 0 + + async def test_a_new_display_name_updates_the_row(self, install): + async with _client(_whoami_app()) as client: + await client.get("/api/whoami", headers=_as(ALICE, "Alice")) + res = await client.get("/api/whoami", headers=_as(ALICE, "Alice Smith")) + nameless = await client.get("/api/whoami", headers=_as(ALICE)) + assert res.json()["display_name"] == "Alice Smith" + assert nameless.json()["display_name"] is None + assert (await install.db.get_actor_ref(ALICE))["display_name"] is None + assert len(await install.humans()) == 1 + + async def test_an_unchanged_name_takes_no_write_lock(self, install, monkeypatch): + writes: list[str] = [] + original = install.db._write + + async def _counting(sql, params=()): + writes.append(sql) + return await original(sql, params) + + monkeypatch.setattr(install.db, "_write", _counting) + async with _client(_whoami_app()) as client: + for _ in range(3): + await client.get("/api/whoami", headers=_as(ALICE, "Alice")) + assert len(writes) == 1 + await client.get("/api/whoami", headers=_as(ALICE, "Alice Smith")) + assert len(writes) == 2 + await client.get("/api/whoami", headers=_as(BOB, "Bob")) + assert len(writes) == 3 + + async def test_the_system_actor_id_is_refused(self, install): + """The schema trigger aborts the insert; the request gets 401, not 500.""" + system_id = install.system_actor_id + before = await install.db.get_actor_ref(system_id) + async with _client(_whoami_app()) as client: + res = await client.get("/api/whoami", headers=_as(system_id, "Mallory")) + again = await client.get("/api/whoami", headers=_as(system_id.upper())) + assert res.status_code == again.status_code == 401 + assert await install.db.get_actor_ref(system_id) == before + assert before["kind"] == "system" + + +# --------------------------------------------------------------------------- # +# Two people at once # +# --------------------------------------------------------------------------- # + + +@pytest.mark.asyncio +class TestConcurrentPeople: + @staticmethod + def _rendezvous(install, monkeypatch) -> None: + """Hold each request in authentication until a second one arrives, so + the requests overlap. A shared "current actor" would then leak.""" + barrier = asyncio.Barrier(2) + original = install.db.upsert_external_actor + + async def _both_here(actor_id, display_name): + await asyncio.wait_for(barrier.wait(), timeout=5) + await original(actor_id, display_name) + + monkeypatch.setattr(install.db, "upsert_external_actor", _both_here) + + async def test_concurrent_requests_each_see_their_own_actor( + self, install, monkeypatch, + ): + self._rendezvous(install, monkeypatch) + async with _client(_whoami_app()) as client: + results = await asyncio.gather(*( + client.get("/api/whoami", headers=_as(pid, name)) + for pid, name in [(ALICE, "Alice"), (BOB, "Bob")] * 3 + )) + assert [res.json()["actor_id"] for res in results] == [ALICE, BOB] * 3 + assert [res.json()["display_name"] for res in results] == ["Alice", "Bob"] * 3 + + async def test_sessions_and_messages_keep_their_people_apart( + self, install, monkeypatch, + ): + self._rendezvous(install, monkeypatch) + async with _client(server.create_app()) as client: + hers, his = await asyncio.gather( + client.post("/api/sessions", headers=_as(ALICE, "Alice"), json={}), + client.post("/api/sessions", headers=_as(BOB, "Bob"), json={}), + ) + assert hers.status_code == his.status_code == 200 + assert await install.creator_of(hers.json()["id"]) == ALICE + assert await install.creator_of(his.json()["id"]) == BOB + + shared = hers.json()["id"] + sends = await asyncio.gather(*( + client.post( + "/api/sessions/run-later", + headers=_as(pid, name), + json={"session_id": shared, "message": f"from {name}", "delay": "none"}, + ) + for pid, name in [(ALICE, "Alice"), (BOB, "Bob")] + )) + assert [res.status_code for res in sends] == [200, 200] + assert sorted(await install.said_in(shared)) == sorted([ + ("from Alice", ALICE), ("from Bob", BOB), + ]) + + async def test_alternating_requests_never_reuse_an_earlier_actor(self, install): + expected = [ + (_as(ALICE, "Alice"), ALICE), + (_as(BOB, "Bob"), BOB), + (_bearer(create_system_token(_SECRET)), install.system_actor_id), + (_as(ALICE, "Alice"), ALICE), + ] + async with _client(_whoami_app()) as client: + for headers, actor_id in expected: + res = await client.get("/api/whoami", headers=headers) + assert res.status_code == 200 + assert res.json()["actor_id"] == actor_id + + +# --------------------------------------------------------------------------- # +# Tokens # +# --------------------------------------------------------------------------- # + + +class _RecordingManager: + """Stands in for the MCP session manager; counts admitted requests.""" + + def __init__(self): + self.calls = 0 + + async def handle_request(self, scope, receive, send): + self.calls += 1 + await send({ + "type": "http.response.start", + "status": 200, + "headers": [(b"content-type", b"application/json")], + }) + await send({"type": "http.response.body", "body": b"{}", "more_body": False}) + + +@pytest.mark.asyncio +class TestTokens: + async def test_session_tokens_are_refused_on_rest(self, install): + account_id = await install.add_leftover_account() + async with _client(_whoami_app()) as client: + session = await client.get( + "/api/whoami", headers=_bearer(create_session_token(_SECRET, account_id)), + ) + legacy = await client.get("/api/whoami", headers=_bearer(_legacy_token())) + assert session.status_code == legacy.status_code == 401 + assert session.json()["detail"] == EXTERNAL_SESSION_DETAIL + assert legacy.json()["detail"] == EXTERNAL_SESSION_DETAIL + + async def test_system_tokens_give_the_system_actor(self, install): + async with _client(_whoami_app()) as client: + res = await client.get( + "/api/whoami", headers=_bearer(create_system_token(_SECRET)), + ) + assert res.status_code == 200 + assert res.json()["actor_id"] == install.system_actor_id + assert res.json()["kind"] == "system" + + def _mcp_app(self, manager) -> FastAPI: + from nerve.config import McpEndpointConfig, get_config + from nerve.mcp_server.http import mount_deferred + + config = get_config() + config.mcp_endpoint = McpEndpointConfig(enabled=True, path="/mcp/v1") + app = FastAPI() + mount_deferred(app, config, lambda: manager) + return app + + async def test_the_mcp_endpoint_refuses_session_tokens(self, install): + account_id = await install.add_leftover_account() + manager = _RecordingManager() + async with _client(self._mcp_app(manager)) as client: + session = await client.post( + "/mcp/v1/", + headers=_bearer(create_session_token(_SECRET, account_id)), + content=b"{}", + ) + legacy = await client.post( + "/mcp/v1/", headers=_bearer(_legacy_token()), content=b"{}", + ) + assert session.status_code == legacy.status_code == 401 + assert manager.calls == 0 + + async def test_the_mcp_endpoint_admits_system_and_mcp_tokens(self, install): + manager = _RecordingManager() + tokens = [ + create_system_token(_SECRET), + create_mcp_session_token(_SECRET, "engine-sess-1"), + create_external_mcp_token(_SECRET), + ] + async with _client(self._mcp_app(manager)) as client: + for token in tokens: + res = await client.post("/mcp/v1/", headers=_bearer(token), content=b"{}") + assert res.status_code == 200 + assert manager.calls == len(tokens) + + def _worker_app(self, monkeypatch) -> FastAPI: + from nerve.config import get_config + from nerve.gateway.routes import codex as codex_routes + + monkeypatch.setattr( + codex_routes, "get_deps", + lambda: SimpleNamespace(engine=SimpleNamespace(config=get_config())), + ) + app = FastAPI() + app.include_router(codex_routes.router) + return app + + async def test_the_worker_token_route_refuses_session_tokens( + self, install, monkeypatch, + ): + account_id = await install.add_leftover_account() + worker = {"worker_id": "ultracode-0123456789abcdef"} + async with _client(self._worker_app(monkeypatch)) as client: + refused = await client.post( + "/api/codex/worker-token", + headers=_bearer(create_session_token(_SECRET, account_id)), + json=worker, + ) + exchanged = await client.post( + "/api/codex/worker-token", + headers=_bearer(create_mcp_session_token(_SECRET, "engine-sess-1")), + json=worker, + ) + assert refused.status_code == 401 + assert refused.json()["detail"] == EXTERNAL_SESSION_DETAIL + assert exchanged.status_code == 200 + + +# --------------------------------------------------------------------------- # +# Routes # +# --------------------------------------------------------------------------- # + + +class TestRoutes: + def test_account_and_setup_routes_are_not_registered(self, external): + paths_in_use = _route_paths(server.create_app()) + assert not any(path.startswith("/api/accounts") for path in paths_in_use) + assert "/api/setup/claim" not in paths_in_use + for kept in ("/api/auth/check", "/api/auth/me", "/api/auth/status", "/api/actors"): + assert kept in paths_in_use + + def test_local_mode_registers_them(self): + paths_in_use = _route_paths(server.create_app()) + assert "/api/accounts" in paths_in_use + assert "/api/setup/claim" in paths_in_use + + @pytest.mark.asyncio + async def test_login_answers_404(self, install): + async with _client(server.create_app()) as client: + with_body = await client.post("/api/auth/login", json={"password": "x"}) + no_body = await client.post("/api/auth/login", json={}) + assert with_body.status_code == no_body.status_code == 404 + + @pytest.mark.asyncio + async def test_status_reports_the_mode_without_reading_login_state( + self, install, monkeypatch, + ): + async def _no_login_state(): + raise AssertionError("external mode must not read login state") + + monkeypatch.setattr(install.db, "login_state", _no_login_state) + async with _client(server.create_app()) as client: + res = await client.get("/api/auth/status") + assert res.status_code == 200 + assert res.json() == { + "mode": "external", "auth_required": True, "login": "username_password", + } + + +# --------------------------------------------------------------------------- # +# The WebSocket # +# --------------------------------------------------------------------------- # + + +class _RecordingEngine: + """Enough engine for ``/ws``; records what each message runs as.""" + + def __init__(self): + self.session_actors: list[Actor] = [] + self.runs: list[dict] = [] + self.router = SimpleNamespace(get_last_session=self._no_session) + self.sessions = SimpleNamespace(get_active_session=self._session) + + async def _no_session(self, *_args, **_kwargs): + return None + + async def _session(self, *_args, actor=None, **_kwargs): + self.session_actors.append(actor) + return "ws-session" + + def is_session_running(self, *_args, **_kwargs): + return False + + async def run(self, **kwargs): + self.runs.append(kwargs) + + def register_task(self, *_args, **_kwargs): + return None + + +async def _open_external_db(db_path, config) -> Database: + database = Database(db_path) + await database.connect() + await bootstrap_identity(database, config) + return database + + +@pytest.fixture +def ws_instance(tmp_path, external, wire_identity_store, monkeypatch): + """The real ``/ws`` endpoint of ``create_app()`` in external mode.""" + config = _config(tmp_path) + set_config(config) + app = server.create_app() + app.router.lifespan_context = _no_lifespan + engine = _RecordingEngine() + with TestClient(app) as client: + database = client.portal.call(_open_external_db, tmp_path / "nerve.db", config) + wire_identity_store(database) + monkeypatch.setattr(server, "_engine", engine, raising=False) + try: + yield SimpleNamespace(client=client, db=database, engine=engine) + finally: + client.portal.call(database.close) + set_config(NerveConfig()) + + +def _wait_for(predicate, *, within: float = 5.0) -> None: + deadline = time.monotonic() + within + while not predicate(): + if time.monotonic() > deadline: + pytest.fail("the condition did not become true in time") + time.sleep(0.01) + + +def _close_code(client: TestClient, url: str, headers: dict | None = None) -> int: + with pytest.raises(WebSocketDisconnect) as ei: + with client.websocket_connect(url, headers=headers or {}) as socket: + socket.receive_json() + return ei.value.code + + +class TestWebSocket: + def test_an_external_person_is_admitted_and_keeps_the_actor(self, ws_instance): + client, engine = ws_instance.client, ws_instance.engine + with client.websocket_connect("/ws", headers=_as(ALICE, "Alice")) as socket: + assert socket.receive_json() == { + "type": "session_switched", "session_id": "ws-session", + } + (connection, _socket), = server._live_sockets.values() + assert connection.actor == Actor( + actor_id=ALICE, kind="human", account_id=None, display_name="Alice", + ) + assert engine.session_actors == [connection.actor] + + socket.send_json({"type": "message", "content": "one", "session_id": "ws-session"}) + _wait_for(lambda: len(engine.runs) == 1) + + # A later request renames the person. The open connection keeps the + # actor it was admitted with. + renamed = client.get("/api/auth/me", headers=_as(ALICE, "Alice Smith")) + assert renamed.json()["actor"]["display_name"] == "Alice Smith" + + socket.send_json({"type": "message", "content": "two", "session_id": "ws-session"}) + _wait_for(lambda: len(engine.runs) == 2) + socket.send_json({"type": "ping"}) + assert socket.receive_json() == {"type": "pong"} + + assert [run["actor"] for run in engine.runs] == [connection.actor] * 2 + assert [run["user_message"] for run in engine.runs] == ["one", "two"] + assert server._live_sockets == {} + + def test_refused_upgrades_close_with_4001(self, ws_instance): + client = ws_instance.client + account_id = client.portal.call( + functools.partial(ws_instance.db._bootstrap_first_account, credential_source="none"), + ).account_id + session = create_session_token(_SECRET, account_id) + assert _close_code(client, "/ws") == 4001 + assert _close_code(client, f"/ws?token={session}") == 4001 + assert _close_code(client, f"/ws?token={_legacy_token()}") == 4001 + assert _close_code(client, "/ws", {ACTOR_CONTEXT_HEADER: "abc"}) == 4001 + assert _close_code( + client, "/ws", _as(ws_instance.db.system_actor_id, "Mallory"), + ) == 4001 + + def test_a_system_token_is_still_admitted(self, ws_instance): + token = create_system_token(_SECRET) + with ws_instance.client.websocket_connect(f"/ws?token={token}") as socket: + assert socket.receive_json()["type"] == "session_switched" + (connection, _socket), = server._live_sockets.values() + assert connection.actor.is_system + + +class _Socket: + """The parts of a WebSocket that the ``/ws`` handler touches.""" + + def __init__(self, headers: dict, frames: list[dict]): + self.headers = headers + self.query_params = {} + self.cookies = {} + self.sent: list[dict] = [] + self._frames = list(frames) + + async def accept(self) -> None: + return None + + async def close(self, code: int = 1000, reason: str = "") -> None: + self.sent.append({"closed": code}) + + async def send_json(self, payload: dict) -> None: + self.sent.append(payload) + + async def receive_json(self) -> dict: + if not self._frames: + raise WebSocketDisconnect(1000) + return self._frames.pop(0) + + +def _ws_endpoint(): + app = server.create_app() + return next( + route.endpoint for route in app.routes if getattr(route, "path", "") == "/ws" + ) + + +async def _wait_for_user_messages(install, session_id: str, count: int) -> None: + for _ in range(500): + if len(await install.said_in(session_id)) >= count: + return + await asyncio.sleep(0.01) + raise AssertionError(f"{session_id} did not get {count} user messages") + + +@pytest.mark.asyncio +class TestWebSocketAttribution: + async def test_messages_are_attributed_to_each_connection(self, install, monkeypatch): + async def _no_model(*_args, **_kwargs): + raise RuntimeError("no model in this test") + + monkeypatch.setattr(install.engine, "_get_or_create_client", _no_model) + monkeypatch.setattr(server, "_engine", install.engine) + endpoint = _ws_endpoint() + session_id = "ws-shared" + await install.db.create_session(session_id, source="web", actor=None) + + echoes: list[dict] = [] + await broadcaster.register( + session_id, "listener", lambda _sid, msg: echoes.append(msg), + ) + try: + await asyncio.gather(*( + endpoint(_Socket(_as(pid, name), [{ + "type": "message", "content": f"from {name}", + "session_id": session_id, + }])) + for pid, name in [(ALICE, "Alice"), (BOB, "Bob")] + )) + await _wait_for_user_messages(install, session_id, 2) + finally: + await broadcaster.unregister(session_id, "listener") + + assert sorted(await install.said_in(session_id)) == sorted([ + ("from Alice", ALICE), ("from Bob", BOB), + ]) + assert { + (msg["content"], msg["actor_id"]) + for msg in echoes if msg.get("type") == "user_message" + } == {("from Alice", ALICE), ("from Bob", BOB)} + + async def test_a_session_created_over_the_socket_belongs_to_the_person( + self, install, monkeypatch, + ): + monkeypatch.setattr(server, "_engine", install.engine) + socket = _Socket(_as(BOB, "Bob"), []) + await _ws_endpoint()(socket) + switched = next(msg for msg in socket.sent if msg.get("type") == "session_switched") + assert await install.creator_of(switched["session_id"]) == BOB + + +# --------------------------------------------------------------------------- # +# Startup # +# --------------------------------------------------------------------------- # + + +class _ProxyThatFails: + """Stops the lifespan right after the identity and setup-token steps.""" + + async def start(self): + raise RuntimeError("stop after the identity steps") + + async def stop(self): + return None + + +class TestStartup: + @pytest.mark.asyncio + async def test_the_bootstrap_creates_no_account(self, install): + assert await install.db.count_accounts() == 0 + assert await install.humans() == [] + assert not install.report.bootstrapped_account + assert pinned_jwt_secret() == _SECRET + + @pytest.mark.asyncio + async def test_the_bootstrap_still_generates_a_signing_secret(self, tmp_path, external): + database = Database(tmp_path / "nerve.db") + await database.connect() + try: + report = await bootstrap_identity(database, NerveConfig()) + assert report.generated_jwt_secret and not report.bootstrapped_account + assert await database.count_accounts() == 0 + assert pinned_jwt_secret() + finally: + await database.close() + + def test_nerve_init_creates_no_account(self, monkeypatch): + """``nerve init`` does not start the gateway, so it reads the mode + from the environment.""" + from nerve.migrate import bootstrap_identity_sync + + monkeypatch.setenv(AUTH_MODE_ENV, "external") + report = bootstrap_identity_sync( + NerveConfig(), display_name="Alice", passwordless=True, + ) + assert not report.bootstrapped_account + assert inspect_bootstrap_state(paths.db_path()) == ([], True) + + def test_the_identity_preview_reports_no_account(self, monkeypatch): + from nerve.migrate import _inspect_identity + + monkeypatch.setenv(AUTH_MODE_ENV, "external") + report = MigrationReport(dry_run=True) + _inspect_identity(NerveConfig(), paths.db_path(), report) + assert not report.bootstrapped_account + assert report.generated_jwt_secret + + @pytest.mark.asyncio + async def test_startup_deletes_a_setup_token_left_from_local_mode( + self, tmp_path, monkeypatch, + ): + import nerve.proxy.service as proxy_module + from nerve import setup_token + + database = Database(paths.db_path()) + await database.connect() + try: + assert await setup_token.ensure_setup_token(database, unclaimed=True) + finally: + await database.close() + + config = NerveConfig() + config.workspace = tmp_path / "ws" + config.workspace.mkdir() + config.proxy.enabled = True + config.mcp_endpoint.enabled = False + set_config(config) + try: + monkeypatch.setenv(AUTH_MODE_ENV, "external") + app = server.create_app() + monkeypatch.setattr(proxy_module, "ProxyService", lambda cfg: _ProxyThatFails()) + with pytest.raises(RuntimeError, match="stop after the identity steps"): + async with server.lifespan(app): + pass + finally: + set_config(NerveConfig()) + + assert inspect_bootstrap_state(paths.db_path()) == ([], True) + assert read_instance_secret(paths.db_path(), setup_token.SETUP_TOKEN_NAME) == "" + + def test_nerve_start_refuses_an_unknown_mode(self, tmp_path, monkeypatch): + """The command stops before it starts a daemon that would stop at once.""" + from click.testing import CliRunner + + from nerve import cli + + TestDoctor._config(tmp_path) + monkeypatch.setattr( + cli, "_get_daemon_status", + lambda: pytest.fail("start must stop before it looks for a daemon"), + ) + monkeypatch.setenv(AUTH_MODE_ENV, "hosted") + result = CliRunner().invoke(cli.main, ["-c", str(tmp_path / "cfg"), "start"]) + assert result.exit_code == 1 + assert f"{AUTH_MODE_ENV} must be one of" in result.output + + +class TestDoctor: + @staticmethod + def _config(tmp_path) -> NerveConfig: + config_dir, workspace = tmp_path / "cfg", tmp_path / "ws" + config_dir.mkdir() + (workspace / "config").mkdir(parents=True) + (config_dir / "config.yaml").write_text( + f"workspace: {workspace}\n", encoding="utf-8", + ) + (config_dir / "config.local.yaml").write_text("{}\n", encoding="utf-8") + return load_config(config_dir) + + @pytest.mark.asyncio + async def test_no_warning_about_a_missing_local_account(self, tmp_path, monkeypatch): + from nerve.cli import doctor_report + + database = Database(paths.db_path()) + await database.connect() + await database.close() + config = self._config(tmp_path) + + assert "No local account" in doctor_report(config) + monkeypatch.setenv(AUTH_MODE_ENV, "external") + report = doctor_report(config) + assert "No local account" not in report + assert "Auth mode: external" in report + + def test_an_unknown_mode_is_an_error(self, tmp_path, monkeypatch): + from nerve.cli import doctor_report + + monkeypatch.setenv(AUTH_MODE_ENV, "hosted") + report = doctor_report(self._config(tmp_path)) + assert f"[ERR] {AUTH_MODE_ENV} must be one of" in report diff --git a/tests/test_login_and_status.py b/tests/test_login_and_status.py index 8143b474b..f74e24b10 100644 --- a/tests/test_login_and_status.py +++ b/tests/test_login_and_status.py @@ -399,20 +399,20 @@ class TestStatusDescriptor: async def test_setup_required(self, install): async with _client(install.app) as client: body = (await client.get("/api/auth/status")).json() - assert body == {"auth_required": True, "login": "setup"} + assert body == {"mode": "local", "auth_required": True, "login": "setup"} async def test_passwordless(self, install): assert await install.db.complete_passwordless_setup() async with _client(install.app) as client: body = (await client.get("/api/auth/status")).json() - assert body == {"auth_required": False, "login": "none"} + assert body == {"mode": "local", "auth_required": False, "login": "none"} async def test_naming_the_account_stays_passwordless(self, install): """A username alone does not complete setup and is not a credential.""" await install.db.update_account_login(install.owner_id, username="alice") async with _client(install.app) as client: body = (await client.get("/api/auth/status")).json() - assert body == {"auth_required": True, "login": "setup"} + assert body == {"mode": "local", "auth_required": True, "login": "setup"} async def test_only_a_password_ends_passwordless_login(self, install): await install.db.update_account_login(install.owner_id, username="alice") @@ -421,20 +421,20 @@ async def test_only_a_password_ends_passwordless_login(self, install): ) async with _client(install.app) as client: body = (await client.get("/api/auth/status")).json() - assert body == {"auth_required": True, "login": "password"} + assert body == {"mode": "local", "auth_required": True, "login": "password"} async def test_password_only(self, install): await install.secure_the_owner("alice") async with _client(install.app) as client: body = (await client.get("/api/auth/status")).json() - assert body == {"auth_required": True, "login": "password"} + assert body == {"mode": "local", "auth_required": True, "login": "password"} async def test_username_and_password(self, install): await install.secure_the_owner("alice") await install.add_account("bob") async with _client(install.app) as client: body = (await client.get("/api/auth/status")).json() - assert body == {"auth_required": True, "login": "username_password"} + assert body == {"mode": "local", "auth_required": True, "login": "username_password"} async def test_a_configured_password_is_not_passwordless(self, install): """The row still says `none` — a reload added the hash and no restart diff --git a/tests/test_setup_wizard.py b/tests/test_setup_wizard.py index fbe2a8018..99180cb62 100644 --- a/tests/test_setup_wizard.py +++ b/tests/test_setup_wizard.py @@ -335,7 +335,7 @@ async def _status(install: _Install) -> dict: @pytest.mark.asyncio class TestSetupState: async def test_setup_required_refuses_login_and_reports_setup(self, install): - assert await _status(install) == {"auth_required": True, "login": "setup"} + assert await _status(install) == {"mode": "local", "auth_required": True, "login": "setup"} async with _client(install.app) as http: response = await http.post("/api/auth/login", json={"password": ""}) assert response.status_code == 409 @@ -353,7 +353,7 @@ async def test_passwordless_claim_completes_setup_without_a_password( assert account["username"] is None assert await install.db.setup_completed() assert await setup_token.stored_setup_token(install.db) == "" - assert await _status(install) == {"auth_required": False, "login": "none"} + assert await _status(install) == {"mode": "local", "auth_required": False, "login": "none"} async with _client(install.app, token=claimed) as http: assert (await http.get("/api/accounts/me")).status_code == 200 @@ -402,7 +402,7 @@ async def test_a_password_can_be_added_after_a_passwordless_setup( "/api/accounts/me/password", json={"new_password": _PASSWORD}, ) assert response.status_code == 200, response.text - assert await _status(install) == {"auth_required": True, "login": "password"} + assert await _status(install) == {"mode": "local", "auth_required": True, "login": "password"} async def test_password_claim_records_setup_complete(self, install): assert (await _claim(install)).status_code == 200 @@ -414,7 +414,7 @@ async def test_a_configured_password_means_setup_is_not_required( set_config(NerveConfig(auth=AuthConfig( jwt_secret=_SECRET, password_hash=hash_password(_PASSWORD), ))) - assert await _status(install) == {"auth_required": True, "login": "password"} + assert await _status(install) == {"mode": "local", "auth_required": True, "login": "password"} @pytest.mark.asyncio From 83506e955550c14c21cfe5f5f36b1a63a2eaf126 Mon Sep 17 00:00:00 2001 From: Alex Soffronow Pagonidis <237136924+alex-clickhouse@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:58:02 +0000 Subject: [PATCH 2/2] Refuse unstorable names and repeated actor headers in external mode Review of the external mode found these gaps: - A display name with a lone UTF-16 surrogate is valid JSON, but SQLite cannot store it. The upsert raised UnicodeEncodeError, so REST gave 500 and /ws did not get its 4001 close. decode_actor_context() now refuses such a name, so both paths give the normal refusal. - A request with the actor context header twice used the first value. REST and the WebSocket upgrade now read every value and refuse a request that has more than one. - nerve restart stopped the running daemon before the new daemon read NERVE_AUTH_MODE. It now checks the value first, as nerve start does. - nerve doctor showed the mode only in external mode. It now always shows the mode, and says that it reads NERVE_AUTH_MODE in its own shell, not from the running server. The setup-token startup test seeds an unclaimed local account, so that it fails without the external-mode guard. New tests cover the surrogate name and repeated headers on REST and /ws, the header alone on /ws in local mode, and nerve restart with an unknown mode. Co-Authored-By: Claude Opus 5.5 --- docs/accounts.md | 11 +- docs/config.md | 8 +- nerve/cli.py | 56 +++++++--- nerve/gateway/auth.py | 35 +++++-- nerve/gateway/routes/auth.py | 3 +- tests/test_external_auth.py | 197 +++++++++++++++++++++++++++++++---- 6 files changed, 256 insertions(+), 54 deletions(-) diff --git a/docs/accounts.md b/docs/accounts.md index e7bd1fb3c..ed45ebe67 100644 --- a/docs/accounts.md +++ b/docs/accounts.md @@ -134,11 +134,12 @@ and names the person behind each request. See The header is a compact JWS. Nerve reads `principal_id` and `profile.display_name` from its payload. It does not check the signature or any other claim. `principal_id` must be a UUID, and Nerve uses its lowercase -form as the actor ID. A header that Nerve cannot read, or that names the system -actor, gives `401`. When the header is present, Nerve does not read -`Authorization`, the `nerve_token` cookie or `?token=`. The gateway also sends -the header on the WebSocket upgrade request. The connection keeps the actor of -that request until it closes. +form as the actor ID. `profile.display_name` must be a string, `null`, or +absent. A header that Nerve cannot read, a header that names the system actor, +and a request with more than one header give `401`. When the header is +present, Nerve does not read `Authorization`, the `nerve_token` cookie or +`?token=`. The gateway also sends the header on the WebSocket upgrade request. +The connection keeps the actor of that request until it closes. Nerve adds an `actor_refs` row of kind `human` when it sees a principal for the first time, and writes the display name again when it changes. The row has no diff --git a/docs/config.md b/docs/config.md index 0cc8094b0..254e9cb36 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1763,8 +1763,12 @@ server starts. A configuration reload does not change it; a restart does. | `local` | Default, also when the variable is unset or empty. Local accounts, login and session tokens. | | `external` | A gateway in front of Nerve names the person behind each request in the `X-Nerve-Actor-Context` header. Local login, account management and the setup page are not available. See [External mode](accounts.md#external-mode). | -Any other value stops `nerve start` and the server with an error. `nerve doctor` -shows the mode. +Any other value stops `nerve start`, `nerve restart` and the server with an +error. `nerve restart` checks the value before it stops the running daemon. + +`nerve doctor` shows the mode that `NERVE_AUTH_MODE` sets in the shell that +runs `nerve doctor`. It does not ask the running server, so the mode of a +daemon that started with a different environment can be different. > **Warning:** In external mode, Nerve trusts the `X-Nerve-Actor-Context` > header and does not check its signature. A caller that can send a request to diff --git a/nerve/cli.py b/nerve/cli.py index fec7ce162..92febfc1b 100644 --- a/nerve/cli.py +++ b/nerve/cli.py @@ -318,17 +318,13 @@ def init(ctx: click.Context, if_needed: bool, non_interactive: bool, inside_dock ) -@main.command() -@click.option("--foreground", "-f", is_flag=True, help="Run in foreground (don't daemonize)") -@click.pass_context -def start(ctx: click.Context, foreground: bool) -> None: - """Start the Nerve server.""" - config_dir = Path(ctx.obj["config_dir"]) - config = ctx.obj["config"] +def _refuse_unknown_auth_mode() -> None: + """Stop the command when ``NERVE_AUTH_MODE`` has an unknown value. - # The server reads NERVE_AUTH_MODE when it starts and refuses an unknown - # value. Check it here too, so that this command shows the error and - # does not start a daemon that stops at once. + The server reads the variable when it starts and refuses an unknown + value. ``start`` and ``restart`` check it first, so that they show the + error, and do not stop the running daemon or start one that stops at once. + """ from nerve.config import ConfigError from nerve.gateway.auth import auth_mode_from_env @@ -337,6 +333,16 @@ def start(ctx: click.Context, foreground: bool) -> None: except ConfigError as e: raise click.ClickException(str(e)) from e + +@main.command() +@click.option("--foreground", "-f", is_flag=True, help="Run in foreground (don't daemonize)") +@click.pass_context +def start(ctx: click.Context, foreground: bool) -> None: + """Start the Nerve server.""" + _refuse_unknown_auth_mode() + config_dir = Path(ctx.obj["config_dir"]) + config = ctx.obj["config"] + # Migrate a legacy install to the workspace/config layout if needed # (idempotent, best-effort). Non-destructive — originals kept as *.migrated. # The gateway runs the identity bootstrap when it starts. @@ -506,6 +512,8 @@ def restart(ctx: click.Context, resume_ids: tuple[str, ...]) -> None: the restart: their ids are written to the resume queue now, and the fresh daemon re-drives each interrupted turn on startup. """ + _refuse_unknown_auth_mode() + # Enroll ids before anything else so the queue file is on disk — and # survives even a hard kill — by the time the new instance reads it. # Append mode lets concurrent enrollments from different sessions coexist. @@ -1349,16 +1357,30 @@ def doctor_report(config, config_source: str = "", check_api: bool = False) -> s # the instance. # # In external mode, the gateway names people and local accounts are not - # used, so the account checks do not apply. + # used, so the account checks do not apply. The doctor does not ask the + # running server for its mode: it reads NERVE_AUTH_MODE in its own shell, + # and the environment of the daemon can be different. from nerve.config import ConfigError from nerve.db.accounts import inspect_bootstrap_state, read_setup_required - from nerve.gateway.auth import is_external_mode, source_authenticates + from nerve.gateway.auth import ( + AUTH_MODE_ENV, + AUTH_MODE_EXTERNAL, + auth_mode_from_env, + source_authenticates, + ) + mode_source = f"from {AUTH_MODE_ENV} in this shell, not from the running server" try: - external = is_external_mode() + mode = auth_mode_from_env() except ConfigError as e: - external = False - errors.append(f"[ERR] {e} The gateway does not start with this value.") + mode = None + errors.append( + f"[ERR] Auth mode ({mode_source}): {e} The server does not start " + "with this value." + ) + else: + lines.append(f"[OK] Auth mode: {mode} ({mode_source})") + external = mode == AUTH_MODE_EXTERNAL configured = bool(config.auth.password_hash) identity_state = inspect_bootstrap_state(paths.db_path()) @@ -1369,8 +1391,8 @@ def doctor_report(config, config_source: str = "", check_api: bool = False) -> s ] if external: lines.append( - "[OK] Auth mode: external. The gateway names the person behind " - "each request; local accounts are not used" + "[--] Accounts: not used in external mode; the gateway names the " + "person behind each request" ) elif sources is None: lines.append("[--] Accounts: nerve.db not created yet (first start will)") diff --git a/nerve/gateway/auth.py b/nerve/gateway/auth.py index 0a3285e2f..2759454b5 100644 --- a/nerve/gateway/auth.py +++ b/nerve/gateway/auth.py @@ -502,20 +502,35 @@ def decode_actor_context(value: str) -> tuple[str, str | None]: if not isinstance(profile, dict): raise ActorContextError("The actor context profile is not a JSON object") display_name = profile.get("display_name") - if display_name is not None and not isinstance(display_name, str): + if display_name is None: + return principal_id, None + if not isinstance(display_name, str): raise ActorContextError("The actor context display name is not a string") + try: + # JSON can carry a lone UTF-16 surrogate, which SQLite cannot store. + display_name.encode("utf-8") + except UnicodeEncodeError as e: + raise ActorContextError("The actor context display name is not valid UTF-8") from e return principal_id, display_name -async def resolve_external_actor(store: "Database", value: str) -> Actor: - """Resolve an actor context header to a human actor. +async def resolve_external_actor(store: "Database", values: list[str]) -> Actor: + """Resolve the actor context header of a request to a human actor. + + ``values`` holds each actor context header of the request. A request + must have exactly one, so that no reader can take a different value. Adds the ``actor_refs`` row on first sight and writes a changed display name, before the request can write anything that refers to the actor. The actor has no local account. """ + if len(values) != 1: + raise ActorResolutionError( + f"The request must have one {ACTOR_CONTEXT_HEADER} header, " + f"not {len(values)}" + ) try: - actor_id, display_name = decode_actor_context(value) + actor_id, display_name = decode_actor_context(values[0]) except ActorContextError as e: raise ActorResolutionError(str(e)) from e try: @@ -545,13 +560,13 @@ async def require_auth(request: Request) -> Actor: raise HTTPException(status_code=503, detail=NO_SECRET_DETAIL) external = is_external_mode() - context = request.headers.get(ACTOR_CONTEXT_HEADER) if external else None - if context is not None: + contexts = request.headers.getlist(ACTOR_CONTEXT_HEADER) if external else [] + if contexts: store = identity_store() if store is None: raise HTTPException(status_code=503, detail=NO_IDENTITY_DETAIL) try: - return await resolve_external_actor(store, context) + return await resolve_external_actor(store, contexts) except ActorResolutionError as e: raise HTTPException(status_code=401, detail=str(e)) from e @@ -591,13 +606,13 @@ async def authenticate_websocket(websocket: WebSocket) -> Actor | None: return None # fail closed, as require_auth does if is_external_mode(): - context = websocket.headers.get(ACTOR_CONTEXT_HEADER) - if context is not None: + contexts = websocket.headers.getlist(ACTOR_CONTEXT_HEADER) + if contexts: store = identity_store() if store is None: return None try: - return await resolve_external_actor(store, context) + return await resolve_external_actor(store, contexts) except ActorResolutionError as e: logger.info("WebSocket refused: %s", e) return None diff --git a/nerve/gateway/routes/auth.py b/nerve/gateway/routes/auth.py index 839e3b40a..4fa8e716b 100644 --- a/nerve/gateway/routes/auth.py +++ b/nerve/gateway/routes/auth.py @@ -197,7 +197,8 @@ async def _local_login_only() -> None: """Answer 404 in external mode, where the gateway signs people in. FastAPI runs this dependency before it validates the login fields, so a - request with missing or wrong fields also gets 404. + JSON body with missing or wrong fields also gets 404. A body that is not + valid JSON gets 422. """ if is_external_mode(): raise HTTPException(status_code=404, detail="Not Found") diff --git a/tests/test_external_auth.py b/tests/test_external_auth.py index e2b318104..c320ebeeb 100644 --- a/tests/test_external_auth.py +++ b/tests/test_external_auth.py @@ -25,7 +25,8 @@ import pytest_asyncio from fastapi import Depends, FastAPI, Request from fastapi.testclient import TestClient -from starlette.websockets import WebSocketDisconnect +from starlette.datastructures import Headers +from starlette.websockets import WebSocket, WebSocketDisconnect from nerve import paths from nerve.agent.engine import AgentEngine @@ -44,6 +45,7 @@ SESSION_TOKEN_HEADER, ActorContextError, auth_mode, + authenticate_websocket, create_external_mcp_token, create_mcp_session_token, create_session_token, @@ -85,6 +87,17 @@ def _as(principal_id: str, display_name: str | None = None) -> dict: return {ACTOR_CONTEXT_HEADER: _context(principal_id, display_name)} +def _as_both(*people: tuple[str, str]) -> httpx.Headers: + """One actor context header for each person, in one request.""" + return httpx.Headers([ + (ACTOR_CONTEXT_HEADER, _context(pid, name)) for pid, name in people + ]) + + +# Valid JSON and a Python string, but SQLite cannot store a lone surrogate. +_SURROGATE_NAME = _context(ALICE, profile={"display_name": "\ud800"}) + + def _bearer(token: str) -> dict: return {"Authorization": f"Bearer {token}"} @@ -339,12 +352,14 @@ def test_the_signature_and_other_claims_are_ignored(self): _context(ALICE[:-1]), _context(ALICE, profile="Alice"), _context(ALICE, profile={"display_name": 42}), + _SURROGATE_NAME, ], ids=[ "empty", "one-part", "two-parts", "four-parts", "not-base64url", "not-json", "not-utf8", "nested-too-deep", "not-an-object", "no-principal", "number-principal", "name-principal", "short-uuid", "profile-not-object", "name-not-string", + "name-lone-surrogate", ], ) def test_a_malformed_value_is_refused(self, value): @@ -375,8 +390,12 @@ async def test_no_header_and_no_token_is_refused(self, install): assert res.status_code == 401 @pytest.mark.parametrize( - "value", ["", "abc", "a.b.c.d", _context("alice"), _context(42)], - ids=["empty", "one-part", "four-parts", "not-a-uuid", "number"], + "value", + ["", "abc", "a.b.c.d", _context("alice"), _context(42), _SURROGATE_NAME], + ids=[ + "empty", "one-part", "four-parts", "not-a-uuid", "number", + "lone-surrogate-name", + ], ) async def test_a_malformed_header_is_refused(self, install, value): async with _client(_whoami_app()) as client: @@ -386,6 +405,29 @@ async def test_a_malformed_header_is_refused(self, install, value): assert res.status_code == 401 assert await install.humans() == [] + async def test_a_lone_surrogate_in_the_name_is_refused_not_a_server_error( + self, install, + ): + async with _client(server.create_app()) as client: + res = await client.get( + "/api/auth/me", headers={ACTOR_CONTEXT_HEADER: _SURROGATE_NAME}, + ) + assert res.status_code == 401 + assert "display name" in res.json()["detail"] + assert await install.humans() == [] + + @pytest.mark.parametrize( + "people", + [((ALICE, "Alice"), (BOB, "Bob")), ((ALICE, "Alice"), (ALICE, "Alice"))], + ids=["two-people", "the-same-person-twice"], + ) + async def test_more_than_one_header_is_refused(self, install, people): + async with _client(_whoami_app()) as client: + res = await client.get("/api/whoami", headers=_as_both(*people)) + assert res.status_code == 401 + assert ACTOR_CONTEXT_HEADER in res.json()["detail"] + assert await install.humans() == [] + async def test_an_upper_case_uuid_is_the_same_actor(self, install): async with _client(_whoami_app()) as client: lower = await client.get("/api/whoami", headers=_as(ALICE, "Alice")) @@ -772,23 +814,24 @@ def register_task(self, *_args, **_kwargs): return None -async def _open_external_db(db_path, config) -> Database: +async def _open_bootstrapped_db(db_path, config) -> Database: database = Database(db_path) await database.connect() await bootstrap_identity(database, config) return database -@pytest.fixture -def ws_instance(tmp_path, external, wire_identity_store, monkeypatch): - """The real ``/ws`` endpoint of ``create_app()`` in external mode.""" +def _serve_ws(tmp_path, wire_identity_store, monkeypatch): + """The real ``/ws`` endpoint of ``create_app()``, in the mode in force.""" config = _config(tmp_path) set_config(config) app = server.create_app() app.router.lifespan_context = _no_lifespan engine = _RecordingEngine() with TestClient(app) as client: - database = client.portal.call(_open_external_db, tmp_path / "nerve.db", config) + database = client.portal.call( + _open_bootstrapped_db, tmp_path / "nerve.db", config, + ) wire_identity_store(database) monkeypatch.setattr(server, "_engine", engine, raising=False) try: @@ -798,6 +841,18 @@ def ws_instance(tmp_path, external, wire_identity_store, monkeypatch): set_config(NerveConfig()) +@pytest.fixture +def ws_instance(tmp_path, external, wire_identity_store, monkeypatch): + """``/ws`` in external mode.""" + yield from _serve_ws(tmp_path, wire_identity_store, monkeypatch) + + +@pytest.fixture +def local_ws_instance(tmp_path, wire_identity_store, monkeypatch): + """``/ws`` in local mode.""" + yield from _serve_ws(tmp_path, wire_identity_store, monkeypatch) + + def _wait_for(predicate, *, within: float = 5.0) -> None: deadline = time.monotonic() + within while not predicate(): @@ -806,7 +861,9 @@ def _wait_for(predicate, *, within: float = 5.0) -> None: time.sleep(0.01) -def _close_code(client: TestClient, url: str, headers: dict | None = None) -> int: +def _close_code( + client: TestClient, url: str, headers: dict | httpx.Headers | None = None, +) -> int: with pytest.raises(WebSocketDisconnect) as ei: with client.websocket_connect(url, headers=headers or {}) as socket: socket.receive_json() @@ -856,6 +913,30 @@ def test_refused_upgrades_close_with_4001(self, ws_instance): assert _close_code( client, "/ws", _as(ws_instance.db.system_actor_id, "Mallory"), ) == 4001 + assert _close_code( + client, "/ws", {ACTOR_CONTEXT_HEADER: _SURROGATE_NAME}, + ) == 4001 + # The test client joins repeated headers into one value with a comma, + # as some proxies do. TestWebSocketHeaders sends them separately. + assert _close_code( + client, "/ws", _as_both((ALICE, "Alice"), (BOB, "Bob")), + ) == 4001 + assert _close_code( + client, "/ws", _as_both((ALICE, "Alice"), (ALICE, "Alice")), + ) == 4001 + assert server._live_sockets == {} + humans = client.portal.call( + functools.partial(ws_instance.db.list_actor_refs, kind="human"), + ) + assert ALICE not in {row["id"] for row in humans} + assert BOB not in {row["id"] for row in humans} + + def test_the_header_alone_is_refused_in_local_mode(self, local_ws_instance): + """In local mode, ``/ws`` does not read the header at all.""" + client, database = local_ws_instance.client, local_ws_instance.db + assert _close_code(client, "/ws", _as(ALICE, "Alice")) == 4001 + assert client.portal.call(database.get_actor_ref, ALICE) is None + assert local_ws_instance.engine.session_actors == [] def test_a_system_token_is_still_admitted(self, ws_instance): token = create_system_token(_SECRET) @@ -865,11 +946,49 @@ def test_a_system_token_is_still_admitted(self, ws_instance): assert connection.actor.is_system +def _upgrade_request(*values: str) -> WebSocket: + """A WebSocket upgrade request with one header entry for each value.""" + scope = { + "type": "websocket", + "path": "/ws", + "query_string": b"", + "headers": [(ACTOR_CONTEXT_HEADER.lower().encode(), v.encode()) for v in values], + } + + async def _receive(): + return {"type": "websocket.disconnect", "code": 1000} + + async def _send(_message): + return None + + return WebSocket(scope, _receive, _send) + + +@pytest.mark.asyncio +class TestWebSocketHeaders: + async def test_one_header_names_the_person(self, install): + actor = await authenticate_websocket(_upgrade_request(_context(ALICE, "Alice"))) + assert actor == Actor( + actor_id=ALICE, kind="human", account_id=None, display_name="Alice", + ) + + @pytest.mark.parametrize( + "people", + [((ALICE, "Alice"), (BOB, "Bob")), ((ALICE, "Alice"), (ALICE, "Alice"))], + ids=["two-people", "the-same-person-twice"], + ) + async def test_more_than_one_header_refuses_the_socket(self, install, people): + request = _upgrade_request(*(_context(pid, name) for pid, name in people)) + assert len(request.headers.getlist(ACTOR_CONTEXT_HEADER)) == 2 + assert await authenticate_websocket(request) is None + assert await install.humans() == [] + + class _Socket: """The parts of a WebSocket that the ``/ws`` handler touches.""" def __init__(self, headers: dict, frames: list[dict]): - self.headers = headers + self.headers = Headers(headers=headers) self.query_params = {} self.cookies = {} self.sent: list[dict] = [] @@ -1014,18 +1133,25 @@ async def test_startup_deletes_a_setup_token_left_from_local_mode( import nerve.proxy.service as proxy_module from nerve import setup_token + config = NerveConfig() + config.workspace = tmp_path / "ws" + config.workspace.mkdir() + config.proxy.enabled = True + config.mcp_endpoint.enabled = False + + # A local install with unclaimed setup: one account without a + # password, and a setup token. Local mode keeps that token. database = Database(paths.db_path()) await database.connect() try: + assert ( + await database._bootstrap_first_account(credential_source="none") + ).created + assert await setup_token.instance_is_unclaimed(database, config) assert await setup_token.ensure_setup_token(database, unclaimed=True) finally: await database.close() - config = NerveConfig() - config.workspace = tmp_path / "ws" - config.workspace.mkdir() - config.proxy.enabled = True - config.mcp_endpoint.enabled = False set_config(config) try: monkeypatch.setenv(AUTH_MODE_ENV, "external") @@ -1037,7 +1163,8 @@ async def test_startup_deletes_a_setup_token_left_from_local_mode( finally: set_config(NerveConfig()) - assert inspect_bootstrap_state(paths.db_path()) == ([], True) + # The account is left as it is, and no new one is made. + assert inspect_bootstrap_state(paths.db_path()) == (["none"], True) assert read_instance_secret(paths.db_path(), setup_token.SETUP_TOKEN_NAME) == "" def test_nerve_start_refuses_an_unknown_mode(self, tmp_path, monkeypatch): @@ -1056,6 +1183,32 @@ def test_nerve_start_refuses_an_unknown_mode(self, tmp_path, monkeypatch): assert result.exit_code == 1 assert f"{AUTH_MODE_ENV} must be one of" in result.output + @pytest.mark.parametrize("resume", [[], ["--resume", "sess-1"]], ids=["plain", "resume"]) + def test_nerve_restart_refuses_an_unknown_mode_and_keeps_the_daemon( + self, tmp_path, monkeypatch, resume, + ): + """The command stops before it stops the running daemon.""" + from click.testing import CliRunner + + from nerve import cli + + TestDoctor._config(tmp_path) + + def _must_not_run(*_args, **_kwargs): + pytest.fail("restart must stop before it touches the daemon") + + for name in ("_get_daemon_status", "_is_docker_mode", "_is_systemd_managed"): + monkeypatch.setattr(cli, name, _must_not_run) + monkeypatch.setattr(cli.os, "kill", _must_not_run) + monkeypatch.setattr(cli.subprocess, "Popen", _must_not_run) + monkeypatch.setenv(AUTH_MODE_ENV, "hosted") + result = CliRunner().invoke( + cli.main, ["-c", str(tmp_path / "cfg"), "restart", *resume], + ) + assert result.exit_code == 1 + assert f"{AUTH_MODE_ENV} must be one of" in result.output + assert not cli.RESUME_QUEUE_FILE.exists() + class TestDoctor: @staticmethod @@ -1078,15 +1231,21 @@ async def test_no_warning_about_a_missing_local_account(self, tmp_path, monkeypa await database.close() config = self._config(tmp_path) - assert "No local account" in doctor_report(config) + local = doctor_report(config) + assert "No local account" in local + assert f"[OK] Auth mode: local (from {AUTH_MODE_ENV} in this shell" in local + monkeypatch.setenv(AUTH_MODE_ENV, "external") report = doctor_report(config) assert "No local account" not in report - assert "Auth mode: external" in report + assert f"[OK] Auth mode: external (from {AUTH_MODE_ENV} in this shell" in report + assert "not from the running server" in report def test_an_unknown_mode_is_an_error(self, tmp_path, monkeypatch): from nerve.cli import doctor_report monkeypatch.setenv(AUTH_MODE_ENV, "hosted") report = doctor_report(self._config(tmp_path)) - assert f"[ERR] {AUTH_MODE_ENV} must be one of" in report + assert "[ERR] Auth mode" in report + assert f"{AUTH_MODE_ENV} must be one of" in report + assert "[OK] Auth mode" not in report