openclaw/docs/cli/node.md
Peter Steinberger 1fc29beba2
refactor(state): fold singleton tables into config_machine_state at schema v12 (#129876)
* refactor(state): fold singleton tables into config_machine_state at schema v11

Eight singleton tables (skill_curator_state, update_check_state,
clawhub_promotions_feed_state, model_catalog_remote, voicewake_triggers,
voicewake_routing_config, voicewake_routing_routes,
onboarding_recommendations) were each one logical JSON value behind a
fixed key; their bespoke schemas, lazy ensures, and per-table accessors
collapse onto the shared config_machine_state KV under namespaced keys.
cron_store_epochs retires outright: it was born write-only in #114388
and no reader ever existed in any language. Durable values (update
check state, voicewake triggers and routing, per-workspace onboarding
answers) migrate insert-if-absent during the v10->v11 migration; cache
class contents rebuild on next use. Deferred with named reasons:
exec_approvals_config (macOS direct-SQL contract), installed_plugin_index
(same-tx lease fence), node_host_config and web_push_vapid_keys
(secret-table git-backup redaction).

# Conflicts:
#	src/skills/workshop/collection-review-state.ts
#	src/skills/workshop/collection-review.gateway-admission.test.ts

* test: register v11 guard carve-outs and suppression pin

The v11 migration module joins the raw-SQLite allowlist (migrations are
the named guardrail exception), the lint-suppression allowlist records
the second type-parameter suppression in config-machine-state, and the
identity module keeps only externally consumed exports.

* test: surface CLI stderr when migration-diagnostic assertion fails

* test: expect migration diagnostics on stderr for models plain commands

The #129037 pending-migration cases asserted that aliases/fallbacks
lists never open the state database, but config-health observation
(observeConfigSnapshot -> readConfigHealthStateFromStore) full-opens it
on any config read whose file exists — reproduced identically on clean
main with a main-built dist. The protected contract is exact stdout;
the diagnostic legitimately lands on stderr for every case.

* test: drop unused defaults import from CLI stdout e2e

* test: split session path derivation out of oversized session-files suite

#130016 pushed session-files.test.ts to 1008 lines, over the 1000-line
lint cap and red for every PR's check-lint. The sessionPathForFile
describe moves to a self-contained sibling following the existing
session-files.*.test.ts split pattern; no assertions change.

* refactor(state): fold four more singleton tables into schema v12

tui_last_sessions (cache-class, regenerates on next session switch),
sidebar_sections (persistent section order, migrated as one JSON array),
node_host_config, and web_push_vapid_keys join the v12 fold-in, taking
the retirement to thirteen tables at the same version. The two secret
singletons were blocked on table-granular git-backup redaction; backups
now exclude config_machine_state rows by secret key prefix (nodeHost.*,
webPush.vapidKeys) with a fail-closed row filter and regression proof,
so STATE_SECRET_TABLE_NAMES sheds both tables. The sidebar fold also
retires its lazy-ensure WeakSet and inline DDL; sidebar edits stay
inside the existing session-group write transaction via direct Kysely.

* fix(node-host): omit absent Cloudflare Access config like the column reader

The KV rewrite returned gateway.cloudflareAccess as an own undefined
property where the retired column reader omitted the key; toStrictEqual
consumers (state-migrations doctor-repair test) caught the shape drift.
Mirror the column reader's conditional spread at both construction
sites.

* fix(backup): disclose redacted machine-state prefixes after restore

The prefix-granular secret redaction recorded omitted key prefixes in
the backup manifest but the restore result exposed only excludedTables,
so a redacted restore looked complete while nodeHost.* and
webPush.vapidKeys configuration were intentionally absent. The restore
result and CLI output now disclose the omitted prefixes (JSON mode
carries them via the result shape), with restore-side regression
coverage.

* fix(tui): compare-and-delete retired session pointers

Doctor cleanup read matching pointer keys then deleted them
unconditionally, so a replacement pointer written between the scan and
the delete was erased. The delete now re-checks the stored value inside
the write transaction and only removes pointers that still name a
retired session; a live replacement survives (regression covered).
Also corrects the stale schema-version line in database-first.md.
2026-08-26 13:32:19 -07:00

302 lines
14 KiB
Markdown

---
summary: "CLI reference for `openclaw node` (headless node host)"
read_when:
- Running the headless node host
- Pairing a non-macOS node for system.run
title: "Node"
---
# `openclaw node`
Run a **headless node host** that connects to the Gateway WebSocket and exposes
`system.run` / `system.which` on this machine.
On macOS, the menu bar app already embeds this node-host runtime into its own
node connection and adds native Mac capabilities. Use `openclaw node run` on a
Mac only when you intentionally want a headless node without the app. Running
both creates two node identities for the same machine.
## Why use a node host?
Use a node host when you want agents to **run commands on other machines** in your
network without installing a full macOS companion app there.
Common use cases:
- Run commands on remote Linux/Windows boxes (build servers, lab machines, NAS).
- Keep exec **sandboxed** on the gateway, but delegate approved runs to other hosts.
- Provide a lightweight, headless execution target for automation or CI nodes.
Execution is still guarded by **exec approvals** and per-agent allowlists on the
node host, so you can keep command access scoped and explicit.
`openclaw node run` can publish plugin or MCP-backed tools after it connects.
The Gateway trusts descriptors from the paired node by default, while requiring
each descriptor's command to remain in the node's approved command surface. The
agent sees each accepted descriptor as a normal plugin tool, but execution still
goes through `node.invoke`, so disconnecting the node removes the tool from new
agent runs. Gateway operators can disable publication with
`gateway.nodes.pluginTools.enabled: false`.
For declarative MCP tools, add the normal MCP server shape under
`nodeHost.mcp.servers` in `openclaw.json` on the node machine, then restart the
node host. The node declares the approval-gated `mcp.tools.call.v1` command
family and publishes listed tools after connecting; changing the server list
later does not require re-pairing. See
[Node-hosted MCP servers](/nodes#node-hosted-mcp-servers).
## Browser proxy (zero-config)
Node hosts automatically advertise a browser proxy if `browser.enabled` is not
disabled on the node. This lets the agent use browser automation on that node
without extra configuration.
By default, the proxy exposes the node's normal browser profile surface. If you
set `nodeHost.browserProxy.allowProfiles`, the proxy becomes restrictive:
non-allowlisted profile targeting is rejected, and persistent profile
create/delete routes are blocked through the proxy.
Disable it on the node if needed:
```json5
{
nodeHost: {
browserProxy: {
enabled: false,
},
},
}
```
## Run (foreground)
For one-paste onboarding, use [`openclaw connect`](/cli/connect). It accepts a
single-use join URL or the same setup code forms as `--pair`, then runs this
node-host runtime.
```bash
openclaw node run --host <gateway-host> --port 18789
```
Or paste a short-lived node setup link from the Control UI Devices page:
```bash
openclaw node run --pair "oc-pair://<setup-code>"
```
Options:
- `--host <host>`: Gateway WebSocket host (default: `127.0.0.1`)
- `--pair <code-or-url>`: Read the Gateway endpoint, bootstrap token, TLS mode,
and optional certificate pin from a setup code or `oc-pair://` URL. Explicit
gateway flags override values from `--pair`.
- `--port <port>`: Gateway WebSocket port (default: `18789`)
- `--context-path <path>`: Gateway WebSocket context path (e.g. `/openclaw-gw`). Appended to the WebSocket URL.
- `--tls`: Use TLS for the gateway connection
- `--no-tls`: Force a plaintext Gateway connection even when the local Gateway config enables TLS
- `--tls-fingerprint <sha256>`: Expected TLS certificate fingerprint (sha256)
- `--node-id <id>`: Override the client instance ID stored in shared SQLite state (does not reset pairing)
- `--display-name <name>`: Override the node display name
## Gateway auth for node host
`--pair` uses a 10-minute single-use bootstrap token for the first connection.
After pairing, reconnects use the durable device credential. The setup link
does not pre-approve `system.run`; normal node approval and SSH verification
remain in force. `node install --pair` is intentionally unavailable because a
short-lived bearer setup link must not be persisted in service arguments.
`openclaw node run` and `openclaw node install` resolve gateway auth from config/env (no `--token`/`--password` flags on node commands):
- `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD` are checked first.
- Then local config fallback: `gateway.auth.token` / `gateway.auth.password`.
- In local mode, node host intentionally does not inherit `gateway.remote.token` / `gateway.remote.password`.
- If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, node auth resolution fails closed (no remote fallback masking).
- In `gateway.mode=remote`, remote client fields (`gateway.remote.token` / `gateway.remote.password`) are also eligible per remote precedence rules.
- Node host auth resolution only honors `OPENCLAW_GATEWAY_*` env vars.
For a Gateway behind Cloudflare Access, set `CF_ACCESS_CLIENT_ID` and
`CF_ACCESS_CLIENT_SECRET` together before `openclaw connect`, `openclaw node
run`, or `openclaw node install`. The node stores env SecretRefs under its
canonical `gateway.cloudflareAccess.clientId` and `clientSecret` connection
keys. Installed services keep the values in the managed service environment
file, not in service arguments or inline supervisor definitions. Access
credentials require HTTPS/WSS; plaintext HTTP/WS fails before SecretRef
resolution while credential-free plaintext node routes remain unchanged. See
[Gateway deployments that cannot host nodes](/nodes#gateway-deployments-that-cannot-host-nodes).
For a node connecting to a plaintext `ws://` Gateway, loopback, private IP
literals, `.local`, and Tailnet `*.ts.net` hosts are accepted. For other
trusted private-DNS names, set `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`; without
it, node startup fails closed and asks you to use `wss://`, an SSH tunnel, or
Tailscale. This is a process-environment opt-in, not an `openclaw.json` config
key.
`openclaw node install` persists it into the supervised node service when it is
present in the install command environment.
## Service (background)
Install a headless node host as a user service (launchd on macOS, systemd on
Linux, Windows Task Scheduler on Windows).
```bash
openclaw node install --host <gateway-host> --port 18789
```
Options:
- `--host <host>`: Gateway WebSocket host (default: `127.0.0.1`)
- `--port <port>`: Gateway WebSocket port (default: `18789`)
- `--context-path <path>`: Gateway WebSocket context path (e.g. `/openclaw-gw`). Appended to the WebSocket URL.
- `--tls`: Use TLS for the gateway connection
- `--tls-fingerprint <sha256>`: Expected TLS certificate fingerprint (sha256)
- `--node-id <id>`: Override the client instance ID stored in shared SQLite state (does not reset pairing)
- `--display-name <name>`: Override the node display name
- `--runtime <node|bun>`: Service runtime (default: `node`). Bun 1.4+ with WAL-reset-safe `node:sqlite` is an explicit opt-in; Node remains recommended.
- `--force`: Reinstall/overwrite if already installed
> **Linux (systemd user service):** Run `sudo loginctl enable-linger <user>` after
> install. Without lingering, `systemd --user` tears down the node service when
> your last SSH session ends, so the node silently goes offline after logout.
> `openclaw node install` prints this warning when it detects lingering is
> disabled.
Manage the service:
```bash
openclaw node status
openclaw node start
openclaw node stop
openclaw node restart
openclaw node uninstall
```
Use `openclaw node run` for a foreground node host (no service).
Service commands accept `--json` for machine-readable output.
`node start` and `node restart` print install hints and exit nonzero when no
managed node service is installed; run `openclaw node install` first. Stopping
an absent service remains a successful no-op.
The node host retries Gateway restart and network closes in-process. If the
Gateway reports a terminal token/password/bootstrap auth pause, the node host
logs the close detail and exits non-zero so launchd/systemd/Task Scheduler can
restart it with fresh config and credentials. Pairing-required pauses stay in
the foreground flow so the pending request can be approved.
## Pairing
The first connection creates a pending device pairing request (`role: node`) on the Gateway.
When the Gateway host can SSH to the node host non-interactively (same user,
trusted host key), the pending request is approved automatically: the Gateway
runs `openclaw node identity --json` on the node host over SSH and approves on
an exact device-key match. This is on by default; see
[SSH-verified device auto-approval](/gateway/pairing#ssh-verified-device-auto-approval-default)
for requirements and how to disable it (`gateway.nodes.pairing.sshVerify: false`).
Otherwise approve manually via:
```bash
openclaw devices list
openclaw devices approve <requestId>
```
Inspect the local node identity the Gateway verifies against:
```bash
openclaw node identity --json
```
It prints the device ID and public key from the `primary` row in
`state/openclaw.sqlite` and never creates the database or a new identity.
On tightly controlled node networks, the Gateway operator can explicitly opt in
to auto-approving first-time node pairing from trusted CIDRs:
```json5
{
gateway: {
nodes: {
pairing: {
autoApproveCidrs: ["192.168.1.0/24"],
},
},
},
}
```
This is disabled by default (`autoApproveCidrs` is unset). It only applies to
fresh `role: node` pairing with no requested scopes, from a client IP the
Gateway trusts. Operator/browser clients, Control UI, WebChat, and role,
scope, metadata, or public-key upgrades still require manual approval.
If the node retries pairing with changed auth details (role/scopes/public key),
the previous pending request is superseded and a new `requestId` is created.
Run `openclaw devices list` again before approval.
### Identity and pairing state
The headless node separates its client instance ID from the signed device
identity that the Gateway uses for pairing and routing. This state lives in the
OpenClaw state directory (`~/.openclaw` by default, or `$OPENCLAW_STATE_DIR`
when set):
| State | Purpose |
| ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `state/openclaw.sqlite` (`config_machine_state`, key `nodeHost.config`) | Client instance ID, display name, and Gateway connection metadata. The client sends this ID as `instanceId`. |
| `state/openclaw.sqlite` (`device_identities`, `primary`) | Signed Ed25519 keypair and derived device ID. For signed connections, this device ID is the routed node ID and pairing identity. |
| `state/openclaw.sqlite` (`device_auth_tokens`) | Paired device tokens, keyed by cryptographic device ID and role. |
`--node-id` changes only the client instance ID in shared SQLite state. It does
not change the cryptographic device ID or clear pairing auth. Migrating a retired
`node.json` with `openclaw doctor --fix` likewise does not reset pairing. To
revoke and re-pair a node:
1. On the Gateway, run `openclaw nodes remove --node <id|name|ip>`.
2. On the node, restart the installed service with `openclaw node restart`, or
stop and rerun the foreground `openclaw node run` command. This starts the
device-pairing flow. If `openclaw devices list` does not show a request
and the node reports `AUTH_DEVICE_TOKEN_MISMATCH`, restart or rerun it once
more. The rejected attempt clears the now-revoked local token; the next
attempt can request pairing.
3. On the Gateway, run `openclaw devices list`, then
`openclaw devices approve <deviceRequestId>`.
4. Restart or rerun the node again. A client paused for pairing does not resume
automatically after approval; this reconnect creates the separate
command-surface request.
5. On the Gateway, run `openclaw nodes pending`, then
`openclaw nodes approve <nodeRequestId>`.
The two request IDs are distinct. An applicable trusted-CIDR policy can
auto-approve the first-time device-pairing step; command-surface approval remains
a separate check.
Older OpenClaw releases stored node-host state in `node.json`, the signed
identity in `identity/device.json`, and paired auth in
`identity/device-auth.json`. Stop the node host and run
`openclaw doctor --fix` once; Doctor claims each retired source, validates it,
imports and verifies the canonical SQLite row, then removes the old file. Normal
node commands fail closed with this repair instruction while either retired file
or an interrupted Doctor claim remains. Keep `state/openclaw.sqlite` private;
it contains the device keypair and auth tokens.
## Exec approvals
`system.run` is gated by local exec approvals:
- `$OPENCLAW_STATE_DIR/state/openclaw.sqlite#exec_approvals_config`, or
`~/.openclaw/state/openclaw.sqlite#exec_approvals_config` when the variable is unset
- [Exec approvals](/tools/exec-approvals)
- `openclaw approvals --node <id|name|ip>` (edit from the Gateway)
For approved async node exec, OpenClaw prepares a canonical `systemRunPlan`
before prompting. The later approved `system.run` forward reuses that stored
plan, so edits to command/cwd/session fields after the approval request was
created are rejected instead of changing what the node executes.
## Related
- [CLI reference](/cli)
- [Connect a machine](/cli/connect)
- [Nodes](/nodes)