apus node

Run a headless node host that connects to the Gateway WebSocket and exposes system.run / system.which on this machine by default. Use --commands to restrict the advertised surface, for example to read-only session sharing.

On macOS, the menu bar app already embeds this node-host runtime into its own node connection and adds native Mac capabilities. Use apus 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:

Execution is still guarded by exec approvals and per-agent allowlists on the node host, so you can keep command access scoped and explicit.

apus 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 apus.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.

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:

{
  nodeHost: {
    browserProxy: {
      enabled: false,
    },
  },
}

Run (foreground)

For one-paste onboarding, use apus connect. It accepts a single-use join URL or the same setup code forms as --pair, then runs this node-host runtime.

apus node run --host <gateway-host> --port 18789

Or paste a short-lived node setup link from the Control UI Devices page:

apus node run --pair "oc-pair://<setup-code>"

Options:

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. Administrator-minted bootstrap enrollment approves the device and its first declared command surface, including system.run when declared. Later command, capability, or permission expansion still requires apus nodes approve. Gateway command policy and the node host's exec approvals remain separate gates. Local exec approvals default to full with ask: "off"; configure them before using a setup link if that access is too broad. node install --pair is intentionally unavailable because a short-lived bearer setup link must not be persisted in service arguments.

For a managed foreground process, --pair-if-needed reuses native device-token storage across restarts; it does not keep a separate enrollment marker. Preserve the node state directory. After the setup code expires, the node can still reconnect when its saved identity and node token exist and every selected Gateway endpoint matches the saved Gateway scope. The expired bootstrap token is never sent. An expired setup code cannot enroll a new state directory or replace a revoked device token; provision a fresh code when needed. Explicit --pair still rejects expired setup codes.

apus node run and apus node install resolve gateway auth from config/env (no --token/--password flags on node commands):

The saved endpoint includes its host, port, TLS mode, and context path. Changing any of these restores normal config/env auth resolution. A node can therefore share its state directory with a local Gateway while reconnecting to a different paired Gateway, without sending the local Gateway's password on restart.

For a Gateway behind Cloudflare Access, set CF_ACCESS_CLIENT_ID and CF_ACCESS_CLIENT_SECRET together before apus connect, apus node run, or apus 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.

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 APUS_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 apus.json config key. apus 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).

apus node install --host <gateway-host> --port 18789

Options:

The explicit pin is saved in machine-state metadata and retained across restarts and forced reinstalls. Replace it with another --runtime-path, or use apus node install --runtime node --force without --runtime-path to return to automatic selection. An unavailable or unsupported pin fails instead of silently selecting another runtime. Quote paths containing spaces.

Set APUS_WRAPPER to an executable wrapper file to use it instead of the selected runtime and CLI entrypoint. The wrapper receives node run and the connection arguments; it must launch Apus and forward those arguments.

If installation reports a runtime check failure, check the executable and working directory named in the error. For example, when switching users with runuser, first change to a directory that the target user can read. A failed check does not mean that the installed Node version is unsupported; upgrade advice is reserved for missing or unsupported runtimes.

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. apus node install prints this warning when it detects lingering is disabled.

Manage the service:

apus node status
apus node start
apus node stop
apus node restart
apus node uninstall

Use apus node run for a foreground node host (no service). To remove a saved command allowlist, run apus node run --all-commands in the foreground, or reinstall the service with apus node install --force --all-commands. The reset is durable; the replacement service arguments no longer carry --commands.

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 apus 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. While device pairing is pending, the node keeps reconnecting with exponential backoff capped at 30 seconds and connects automatically after approval.

Automatic updates

Long-running packaged node run processes and installed node services check hourly for updates by default. A new version is prepared in a separate node runtime, leaving the global CLI package and a co-located Gateway in place. Activation waits until commands, terminals, workers, plugin work, pending output, and cleanup are idle. The node then restarts with its existing identity, pairing, settings, and launch options. Automatic activations are at least 12 hours apart; there is no deadline that interrupts busy work.

Disable this on the node machine with:

apus config set nodeHost.autoUpdate.enabled false

update.checkOnStart: false and APUS_NO_AUTO_UPDATE=1 also disable node automatic updates. The Gateway's update.auto.enabled preference is separate. Source checkouts, native app nodes, private workers, dev, and extended-stable installs do not auto-apply. Releases requiring database migrations defer to the normal update workflow. See Headless node updates.

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 apus 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 for requirements and how to disable it (gateway.nodes.pairing.sshVerify: false).

Otherwise approve manually via:

apus devices list
apus devices approve <deviceRequestId>

Device approval admits the connection; the command surface needs separate approval. The node keeps reconnecting while device approval is pending, with exponential backoff capped at 30 seconds. After approval, its next reconnect creates a separate command-surface request on the Gateway:

apus nodes pending
apus nodes approve <nodeRequestId>
apus nodes describe --node <idOrNameOrIp>

If an older client already reports that reconnect is paused, restart the installed node with apus node restart, or stop and rerun its foreground apus node run command once.

The device and node request IDs are distinct. An initial unapproved surface has no effective commands. SSH-verified and bootstrap enrollment can approve the first surface automatically; later expansions require approval. Previously approved commands that remain declared and allowed stay effective while an expansion waits.

Inspect the local node identity the Gateway verifies against:

apus node identity --json

It prints the device ID and public key from the primary row in state/apus.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:

{
  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.

Trusted-network device approval does not approve the node's command surface. Inspect apus nodes pending and approve the separate surface request.

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 apus 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 Apus state directory (~/.apus by default, or $APUS_STATE_DIR when set):

State Purpose
state/apus.sqlite (config_machine_state, key nodeHost.config) Client instance ID, display name, and Gateway connection metadata. The client sends this ID as instanceId.
state/apus.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/apus.sqlite (device_auth_tokens) Paired device tokens, keyed by cryptographic device ID and role.

gatewayLocal in node.list and node.describe marks an exact match with the primary device identity in the Gateway's state directory. Overriding --node-id does not change it. A node with its own state directory and key is separate, even on the same machine. Listing or describing nodes does not create identity credentials.

--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 apus doctor --fix likewise does not reset pairing. To revoke and re-pair a node:

  1. On the Gateway, run apus nodes remove --node <id|name|ip>.
  2. On the node, restart the installed service with apus node restart, or stop and rerun the foreground apus node run command. This starts the device-pairing flow. If apus 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 apus devices list, then apus devices approve <deviceRequestId>.
  4. Wait for the node's automatic reconnect, which creates the separate command-surface request. If an older client already paused for pairing, restart or rerun it once.
  5. On the Gateway, run apus nodes pending, then apus 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 Apus 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 apus doctor --fix once; Doctor validates the retired inputs, imports and verifies their canonical SQLite rows, then removes the old files. Node startup, including the macOS app's worker, leaves these inputs for Doctor. Pending device auth or exec approvals stop startup before capabilities are prepared. A missing canonical identity plus retired identity data or an interrupted import claim also stops startup before a new key can be created. An existing valid canonical identity remains authoritative when an older release recreates identity/device.json; Doctor owns that stale file's cleanup. Keep state/apus.sqlite private; it contains the device keypair and auth tokens.

Exec approvals

system.run is gated by local exec approvals:

For approved async node exec, Apus 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