Skip to content
Open
45 changes: 42 additions & 3 deletions docs/accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,13 +160,49 @@ In external mode:
- 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.
- Startup can give the system actor the ID of the agent's system principal.
See [The system actor in external mode](#the-system-actor-in-external-mode).
- 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.

### The system actor in external mode

Each database has one system actor. The schema migration gives it a random
ID. In external mode, the environment can give the ID of the agent's system
principal in `NERVE_SYSTEM_PRINCIPAL_ID` (see
[System principal ID](config.md#system-principal-id)). The identity bootstrap
of `nerve init` and of each server start then gives the system actor that ID
when these conditions are true:

- No row refers to the system actor. Nerve finds each column that has a
foreign key to `actor_refs` in the schema, for example `accounts.actor_id`,
`sessions.created_by_actor_id` and `messages.actor_id`, and checks all of
them. A check that cannot run counts as a row.
- No other actor has the new ID.
- The system actor did not take an ID from the variable before. Nerve records
the ID it took in `nerve.db`, in the same transaction as the change, so the
ID changes at most once, also when two processes start with different
values. A backup made with `--no-secrets` keeps that record, because it is
not secret.

Thus a new installation gets the ID before it attributes work to the system
actor. After the change, system and MCP tokens act as the new ID, and
autonomous work is attributed to it.

When a condition is false, the system actor keeps its current ID and Nerve
logs a warning at each start. Nerve does not change rows that refer to that
ID. `nerve doctor` shows the difference. A value that is not a canonical
lower-case UUID also keeps the current ID and gives a warning. Local mode
ignores the variable.

The schema trigger `system_actor_id_cannot_change` prevents all other changes
to the ID. The bootstrap removes the trigger, changes the ID and creates the
trigger again in one transaction.

## Attribution

Sessions store who created them in `created_by_actor_id`. User messages store
Expand Down Expand Up @@ -218,9 +254,12 @@ password hashes, and the generated signing secret. Treat the archive as a
secret.

`--no-secrets` removes account passwords, the signing secret and the setup
completion record from the database snapshot, omits `config.local.yaml`, and
replaces credential values in workspace configuration with environment-variable
placeholders. It fails if a configuration file cannot be parsed safely.
completion record from the database snapshot, and keeps the record of the
system principal ID that the system actor took (see
[The system actor in external mode](#the-system-actor-in-external-mode)). It
omits `config.local.yaml`, and replaces credential values in workspace
configuration with environment-variable placeholders. It fails if a
configuration file cannot be parsed safely.

After restoring a `--no-secrets` backup:

Expand Down
22 changes: 22 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -1825,6 +1825,28 @@ daemon that started with a different environment can be different.
> Nerve can act as any person. Use external mode only when the gateway is the
> only caller that can reach Nerve.

### System principal ID

The environment variable `NERVE_SYSTEM_PRINCIPAL_ID` gives the ID of the
agent's system principal. It has an effect only in external mode. It is not a
configuration key. `nerve init` and the server read it once, in the identity
bootstrap.

| Value | Behavior |
|---|---|
| Unset or empty | The system actor keeps its ID. |
| A UUID in canonical lower-case form, for example `00000000-0000-4000-8000-000000000001` | The system actor gets this ID when no row refers to the system actor, no other actor has the ID, and the system actor did not take an ID from this variable before. Otherwise the system actor keeps its current ID and Nerve logs a warning. See [The system actor in external mode](accounts.md#the-system-actor-in-external-mode). |
| Any other value, also a UUID in upper case | The system actor keeps its ID and Nerve logs a warning. The server starts. |

Set the variable before the first `nerve init` or start, so that the change
occurs before Nerve attributes work to the system actor. See
[The system actor in external mode](accounts.md#the-system-actor-in-external-mode).

`nerve doctor` reads the variable in its own shell, as it does for
`NERVE_AUTH_MODE`. It shows one of these: the system actor has the ID, the
next start gives it the ID, or the system actor keeps its current ID, with the
reason.

## Setup

| Key | Type | Default | Description |
Expand Down
30 changes: 20 additions & 10 deletions nerve/backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@
accounts and actor identity. ``--no-secrets`` empties its two credentials in
the snapshot: the JWT signing secret in ``instance_secrets`` and each
account's password hash in ``accounts.credential``
(see :func:`_scrub_snapshot_secrets`). It also replaces secret values in the
staged workspace ``config/*.yaml`` with ``${VAR}`` placeholders, because a
tracked ``auth.password_hash`` is still a verifier
(see :func:`_sanitised_config`).
(see :func:`_scrub_snapshot_secrets`). The one ``instance_secrets`` row that
is not secret, the record of the system principal ID, stays. It also
replaces secret values in the staged workspace ``config/*.yaml`` with
``${VAR}`` placeholders, because a tracked ``auth.password_hash`` is still a
verifier (see :func:`_sanitised_config`).
- ``memu.sqlite`` — the entire long-term memory
- the memU sidecar dirs (``memu-conversations/``, ``memu-manual/``, ``memu-resources/``)
- secrets (``certs/``, ``mcp-token``, ``telegram_sync.session``, ``config.local.yaml``)
Expand Down Expand Up @@ -351,14 +352,21 @@ def _snapshot_db(src: Path, dst: Path) -> None:


def _scrub_instance_secrets(snapshot: Path) -> None:
"""Empty ``instance_secrets`` in a snapshot copy of nerve.db.
"""Delete the secrets from ``instance_secrets`` in a snapshot copy of nerve.db.

The table holds the generated JWT signing secret, which can mint tokens for
the live instance, so ``--no-secrets`` must not carry it. This edits the
snapshot before it is checksummed and never touches the live database.
``secure_delete`` overwrites the freed pages. A snapshot without the table
is left as is. The restored instance generates a new secret on first start.
the live instance, so ``--no-secrets`` must not carry it. Every row is
deleted except the record of the system principal ID that the system
actor took (``nerve.db.accounts.SYSTEM_PRINCIPAL_CLAIM_NAME``): it is
instance state, not a secret, and the same ID is in ``actor_refs``. A
restored database that lost it could change its system actor ID again.
This edits the snapshot before it is checksummed and never touches the
live database. ``secure_delete`` overwrites the freed pages. A snapshot
without the table is left as is. The restored instance generates a new
secret on first start.
"""
from nerve.db.accounts import SYSTEM_PRINCIPAL_CLAIM_NAME

conn = _connect(snapshot)
try:
exists = conn.execute(
Expand All @@ -367,7 +375,9 @@ def _scrub_instance_secrets(snapshot: Path) -> None:
if not exists:
return
conn.execute("PRAGMA secure_delete=ON")
conn.execute("DELETE FROM instance_secrets")
conn.execute(
"DELETE FROM instance_secrets WHERE name != ?", (SYSTEM_PRINCIPAL_CLAIM_NAME,),
)
conn.commit()
finally:
conn.close()
Expand Down
65 changes: 60 additions & 5 deletions nerve/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,12 +222,16 @@ def main(ctx: click.Context, config_dir: str | None, verbose: bool) -> None:
"--keep-env", is_flag=True,
help="Slim setup: pass ANTHROPIC_API_KEY/CLAUDE_CODE_OAUTH_TOKEN from this shell to the daemon",
)
@click.option(
"--serve-next", is_flag=True, hidden=True,
help="Slim setup: this process serves next; leave NERVE_SYSTEM_PRINCIPAL_ID to the gateway",
)
@click.pass_context
def init(
ctx: click.Context, if_needed: bool, non_interactive: bool, inside_docker: bool,
terminal: bool, deployment: str | None, workspace_opt: str | None, port: int | None,
bind: str | None, no_start: bool, no_browser: bool, no_clipboard: bool, yes: bool,
keep_env: bool,
keep_env: bool, serve_next: bool = False,
) -> None:
"""Set up Nerve: decide how it runs here, then finish in your browser.

Expand All @@ -248,6 +252,7 @@ def init(
deployment=deployment, workspace=workspace_opt, port=port, bind=bind,
start=not no_start, browser=False if no_browser else None,
clipboard=not no_clipboard, yes=yes, keep_env=keep_env,
system_principal=not serve_next,
)
return

Expand Down Expand Up @@ -738,6 +743,7 @@ def _slim_init(
clipboard: bool,
yes: bool,
keep_env: bool = False,
system_principal: bool = True,
) -> None:
from nerve.bootstrap import SlimFlags, SlimSetup, is_fresh_install

Expand All @@ -761,11 +767,16 @@ def _slim_init(

# The owner account exists before the daemon answers, so the setup
# token is available the moment /health is green. No name: the setup
# page asks.
# page asks. ``system_principal`` is false when this process serves
# next (``nerve start -f``): the gateway's bootstrap reads
# NERVE_SYSTEM_PRINCIPAL_ID and logs the result, once.
from nerve.migrate import bootstrap_identity_sync

try:
bootstrap_identity_sync(config, display_name=None, passwordless=False)
bootstrap_identity_sync(
config, display_name=None, passwordless=False,
system_principal=system_principal,
)
except Exception as e: # noqa: BLE001 — the files are written; say what is missing
raise click.ClickException(
f"Setup wrote the configuration, but the local owner account could not "
Expand Down Expand Up @@ -874,11 +885,13 @@ def start(ctx: click.Context, foreground: bool) -> None:

# 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.
# The gateway runs the identity bootstrap when it starts. It also reads
# NERVE_SYSTEM_PRINCIPAL_ID and logs the result, so the preview here does not.
if config is not None:
from nerve.migrate import maybe_migrate
report = maybe_migrate(
config_dir, workspace=getattr(config, "workspace", None), config=config,
preview_system_principal=False,
)
if report is not None and report.did_anything:
# The config in hand was loaded from the old locations, and migration
Expand All @@ -903,7 +916,7 @@ def start(ctx: click.Context, foreground: bool) -> None:
# `start --foreground` would otherwise exit 1 and leave an
# unsupervised daemon holding the port).
click.echo("Fresh install detected — running setup...")
ctx.invoke(init, no_start=True, no_browser=True, no_clipboard=True)
ctx.invoke(init, no_start=True, no_browser=True, no_clipboard=True, serve_next=True)
config = ctx.obj.get("config")
if config is None:
raise click.ClickException(
Expand Down Expand Up @@ -1553,6 +1566,44 @@ def _telegram_sync_lines(config, warnings: list[str]) -> list[str]:
return lines


def _system_principal_report(*, external: bool) -> tuple[list[str], list[str]]:
"""Doctor lines about ``NERVE_SYSTEM_PRINCIPAL_ID``: ``(lines, warnings)``.

Like the auth mode, the value comes from this shell, not from the running
server. Nothing is reported when the variable is unset here.
"""
from nerve.db.accounts import inspect_system_actor
from nerve.identity import (
SYSTEM_PRINCIPAL_ENV,
InvalidSystemPrincipalId,
system_principal_id_from_env,
)

label = f"System actor ({SYSTEM_PRINCIPAL_ENV} from this shell)"
if not external:
if os.environ.get(SYSTEM_PRINCIPAL_ENV, "").strip():
return [f"[--] {label}: set, but it has no effect in local mode"], []
return [], []
try:
wanted = system_principal_id_from_env()
except InvalidSystemPrincipalId as e:
return [], [f"[WARN] {label}: {e}. The server keeps the current system actor ID."]
if wanted is None:
return [], []
state = inspect_system_actor(paths.db_path(), wanted)
if state is None:
return [f"[--] {label}: no system actor yet; the next start gives it {wanted}"], []
current, blocker = state
if current == wanted:
return [f"[OK] {label}: {current}"], []
if blocker is None:
return [f"[--] {label}: {current}; the next start changes it to {wanted}"], []
return [], [
f"[WARN] {label}: keeps the current ID {current}, not {wanted}: {blocker}. "
"Autonomous work is attributed to that ID."
]


def doctor_report(config, config_source: str = "", check_api: bool = False) -> str:
"""Run doctor checks and return the report as a plain-text string.

Expand Down Expand Up @@ -1991,6 +2042,10 @@ def doctor_report(config, config_source: str = "", check_api: bool = False) -> s
"[WARN] auth.password_hash is set but no account uses it — every "
"account has its own password now; the configured value does nothing"
)
if mode is not None:
ok, warn = _system_principal_report(external=external)
lines.extend(ok)
warnings.extend(warn)

# The setup checklist: the same derivation the setup page uses, over the
# facts this process can read. Never prints the token — `nerve status`
Expand Down
Loading
Loading