Node pairing has two layers, both stored on the paired device record in the Gateway's SQLite state database:

The former standalone node pairing store (nodes/paired.json with a per-node token, retired from the connect path in January 2026) is gone: apus doctor --fix folds remaining rows into the device records and archives the legacy files with a .migrated suffix. Legacy TCP bridge support has been removed.

How capability approval works

  1. A node connects to the Gateway WS (device pairing gates this step).
  2. The Gateway compares the declared capability/command surface with the approved one; new or widened surfaces store a pending request on the device record and emit node.pair.requested.
  3. You approve or reject the request (CLI or UI).
  4. Until approval, node commands stay filtered; approval exposes the declared surface, subject to the normal command policy.

Pending node capability requests do not expire just because time passes. They survive node disconnects and Gateway restarts, and remain pending until approved, rejected, superseded by a changed surface, or cleared by the node lifecycle, such as removal of the node role or a successful reconnect that no longer needs that approval.

A pending capability request does not grant access. Approval still requires the operator scopes for the requested commands. Before updating a live connection, the Gateway checks its pairing identity and generation against the persisted approval and limits access to the capabilities and commands that connection declared.

An initial unapproved surface has no effective commands. While a later expansion waits, previously approved commands remain effective only if the node still declares them and Gateway command policy allows them.

The 5-minute expiry still applies to device-pairing requests, not to capability approvals on an already-paired device.

Upgrades and older writers

Upgrade the Gateway and any CLI that directly writes the same state database together. An older app or CLI that only calls the Gateway over RPC is not a direct database writer; the Gateway handles those requests.

The capability-request storage format is unchanged. Older Gateway or direct CLI writers still apply the former five-minute expiry and can persist deletion of aged requests, including during unrelated pairing updates. Downgrading can therefore restore the old expiry behavior; lifecycle retention is not guaranteed while an older version writes the database.

On upgrade, an aged request that an older version only hid from its output can become visible again if it is still stored. It still requires explicit approval. An upgrade cannot recover an already deleted request: the node must submit a fresh capability request for the operator to review. Existing device pairing and previously approved capabilities are separate from that pending request.

One-paste node pairing

In the Control UI Devices page, open the pairing dialog, choose Node host, and copy the generated command to the device:

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

The setup link carries the Gateway endpoint, a short-lived single-use bootstrap token, and a TLS certificate pin when the Gateway directly serves a pinnable leaf certificate. The bootstrap token expires after 10 minutes. Explicit --host, --port, --context-path, --tls/--no-tls, and --tls-fingerprint flags override values from --pair.

The bootstrap token and resulting device credential are separate, like a short-lived Tailscale auth key and the durable device identity it admits. Revoking or expiring the setup link does not revoke the paired device; remove the device separately when needed. Administrator-minted bootstrap enrollment approves the device and its first declared command surface, including system.run when declared, like SSH-verified device auto-approval. Later command, capability, or permission expansion still requires approval. Gateway command policy and node-local exec approvals remain separate gates. Local exec approvals default to full with ask: "off"; configure them before using a link if that access is too broad.

CLI workflow (headless friendly)

For manual device admission, first run on the Gateway:

apus devices list
apus devices approve <deviceRequestId>

Headless node hosts keep reconnecting while device approval is pending, with exponential backoff capped at 30 seconds. After approval, the next reconnect creates the separate command-surface request:

apus nodes pending
apus nodes approve <nodeRequestId>
apus nodes status
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 command once.

The device and node request IDs are distinct. To reject a surface request or manage an existing node instead:

apus nodes reject <nodeRequestId>
apus nodes remove --node <id|name|ip>
apus nodes rename --node <id|name|ip> --name "Living Room iPad"

nodes status shows paired/connected nodes and their capabilities.

API surface (gateway protocol)

Events:

Methods:

Removed in 2026.7: node.pair.request and node.pair.verify. Pending requests are created by the Gateway itself during node connects, and the standalone per-node token they served no longer exists; node auth is the device pairing token.

Notes:

Here, fs.listDir is the node command relayed through node.invoke. The top-level Gateway fs.listDir RPC needs operator.write for workspace-contained host browsing and operator.admin when nodeId is present.

Node surface approval records the durable command/capability ceiling. It does not grant commands that the node adds later or no longer declares.

  • Live commands must be both declared and approved, then pass the Gateway's command policy (gateway.nodes.commands.allow and gateway.nodes.commands.deny).
  • Shell allowlist and ask policy for system.run live in the node's exec approvals, not in the pairing record.

Node command gating (2026.3.31+)

Breaking change: starting with 2026.3.31, node commands are disabled until node pairing is approved. Device pairing alone is no longer enough to expose declared node commands.

When a node connects for the first time, pairing is requested automatically. Until that request is approved, all pending node commands from that node are filtered and will not execute. Once pairing is approved, the node's declared commands become available, subject to the normal command policy.

This means:

Node event trust boundaries (2026.3.31+)

Breaking change: node-originated runs now stay on a reduced trusted surface.

Node-originated summaries and related session events are restricted to the intended trusted surface. Notification-driven or node-triggered flows that previously relied on broader host or session tool access may need adjustment. This hardening keeps node events from escalating into host-level tool access beyond what the node's trust boundary permits.

Durable node presence updates follow the same identity boundary: the node.presence.alive event is accepted only from authenticated node device sessions, and updates pairing metadata only when the device/node identity is already paired. A self-declared client.id value is not enough to write last-seen state.

Silent local pairing

The Gateway treats a loopback source address as local. This includes a client reaching a remote loopback-only Gateway through an SSH port forward: the SSH server terminates the connection on the Gateway host, so the Gateway sees the forwarded connection as loopback. This is intentional because ordinary SSH access already implies local trust, including the ability to read the shared Gateway token.

By default, trusted local connections silently approve first-time device pairing plus role and scope upgrades. This keeps normal same-host and SSH tunnel reconnects convenient. A native app that already paired as an operator can add its first node role with the same device identity without a device approval prompt, including retries of a pending request labeled as a repair. This preserves its operator token. The Gateway also approves the initial node capability surface directly for a silently paired, non-browser local device, including a native app using remote mode to connect to a Gateway on the same machine. No app-side SSH check or approval panel is needed.

This uses the same-machine, same-user trust boundary. Gateway command denies and node-local OS permissions and exec approvals still apply. Later capability surface upgrades and explicitly revoked node tokens still require approval. Remote, trusted-CIDR, browser, and proxy connections do not gain local approval from a device's earlier silent pairing.

Operators using shell-less, port-forward-only SSH keys or a multi-user Mac can require explicit approval for every device:

{
  gateway: {
    nodes: {
      pairing: {
        autoApproveLocal: false,
      },
    },
  },
}

With this setting, new pairing requests, role upgrades, scope upgrades, and initial node capability requests use the normal approval flow even when the connection is local. Re-enabling local auto-approval allows an unapproved initial surface to be approved on reconnect, including one previously rejected. Metadata-only reconnect refreshes remain automatic so routine client or OS metadata changes do not create approval churn.

SSH-verified device auto-approval (default)

First-time role: node device pairing from a private/CGNAT address is auto-approved when the gateway can prove machine ownership over SSH: it connects back to the pairing host (BatchMode, StrictHostKeyChecking=yes), runs apus node identity --json there, and approves only when the remote device id and public key match the pending request exactly. The key match is what makes this safe: reachability alone never approves, so NAT co-tenants, other users on a shared host, and LAN spoofing all fall through to the normal prompt.

Enabled by default. Requirements for it to fire:

While device approval is pending, the node client is told to keep retrying (wait_then_retry), including while an SSH check is running. If the check fails, the request remains available for manual approval and the node keeps retrying. Failed SSH targets get a short cooldown (5 minutes after a key mismatch).

Pairing settings hot-apply without restarting the Gateway. Automatic approvals recheck the current policy immediately before granting access, even if an SSH check or store lock was already pending when the policy changed. Changes to SSH verification settings use a fresh check and do not inherit the previous policy’s cooldown. Already paired devices remain paired.

Approved devices record approvedVia: "ssh-verified" and their first declared capability surface is approved in the same step — the key match already proves the node runs under the operator's account on a machine they own, which is the same claim a manual capability approval asserts. Later surface upgrades still prompt.

Harden or disable:

{
  gateway: {
    nodes: {
      pairing: {
        // Disable entirely:
        sshVerify: false,
        // ...or scope/tune the check:
        // sshVerify: { user: "me", identity: "~/.ssh/check", timeoutMs: 7000, cidrs: ["10.0.0.0/8"] },
      },
    },
  },
}

Manual approval (macOS app)

The macOS app shows node and device requests in one Apus approval panel. Each request keeps the name, platform, source address, and all requested access visible. System-command execution and device admin access are highlighted. Node requests that Gateway classifies as requiring administrator approval also show a warning for the whole request. Expand Details for the full identity, app/core versions, and request metadata.

Approve Node approves the node's declared capabilities; it does not rotate the device's access token. Command-Return approves only a single displayed request. Return alone does not approve. Not Now or Escape hides the panel without resolving requests. Device-pairing requests retain their normal expiry; node capability requests remain pending until their lifecycle resolves them.

For multiple requests, Approve All and Reject All apply only to the displayed requests. Requests arriving after that view was displayed are not included in the decision.

Auto-approval (macOS app)

The macOS app can attempt a silent approval of node capability requests when:

If silent approval fails, it falls back to the normal Approve/Reject prompt.

Trusted-CIDR device auto-approval

WS device pairing for role: node stays manual by default. For private node networks where the Gateway already trusts the network path, operators can opt in with explicit CIDRs or exact IPs:

{
  gateway: {
    nodes: {
      pairing: {
        autoApproveCidrs: ["192.168.1.0/24"],
      },
    },
  },
}

Security boundary:

Silent pairing supersede cleanup

Non-interactive approvals record their provenance on the paired-device row: same-host local policy approvals as silent, trusted-CIDR node approvals as trusted-cidr, SSH-verified node approvals as ssh-verified. Clients whose state directory is ephemeral (temporary homes, containers, per-run sandboxes) mint a fresh device keypair per run, and every run silently re-pairs as a brand-new device — without cleanup the paired list grows one stale row per run.

When the Gateway silently approves a local device pairing, it retires older silent-approved records that belong to the same client cluster (matching clientId, clientMode, and display name) and are not currently connected. Local clients run on the gateway host itself, so the cluster key cannot match a different machine. Retired rows lose their tokens immediately; any matching legacy node pairing entry is cleared and a node.pair.resolved removal event is broadcast.

Boundaries:

Metadata-upgrade auto-approval

When an already-paired device reconnects with only non-sensitive metadata changes (for example display name or client platform hints), Apus treats that as a metadata-upgrade. Silent auto-approval is narrow: it applies only to trusted non-browser local reconnects that already proved possession of local or shared credentials, including same-host native app reconnects after OS version metadata changes. Browser/Control UI clients and remote clients still use the explicit re-approval flow. Scope upgrades (read to write/admin) and public key changes are not eligible for metadata-upgrade auto-approval; they stay explicit re-approval requests.

QR pairing helpers

/pair qr renders the pairing payload as structured media so mobile and browser clients can scan it directly.

Deleting a device also sweeps any stale pending pairing requests for that device id, so nodes pending does not show orphaned rows after a revoke.

Locality and forwarded headers

Gateway pairing treats a connection as loopback only when both the raw socket and any upstream proxy evidence agree. If a request arrives on loopback but carries Forwarded, any X-Forwarded-*, or X-Real-IP header evidence, that forwarded-header evidence disqualifies the loopback locality claim, and the pairing path requires explicit approval instead of silently treating the request as a same-host connect. See Trusted Proxy Auth for the equivalent rule on operator auth.

Storage (local, private)

Pairing state lives on the paired device records in the shared SQLite state database under the Gateway state directory (default ~/.apus):

If you override APUS_STATE_DIR, the database moves with it. Stop the Gateway and run apus doctor --fix to import stores from older releases. Doctor leaves devices/*.json.migrated and nodes/*.json.migrated archives behind. It imports device approvals before folding node capabilities; existing SQLite approvals take precedence. Normal Gateway startup reports pending legacy stores without changing them.

Security notes:

Transport behavior

Related