Skip to content

feat: explicitly pair agent-box users for direct cross-box messaging #699

Description

@defangdevs

Problem

An agent on one agent-box cannot directly send a message to an agent on another box. The missing piece is not a global user directory: the user can explicitly pair two boxes and thereby supply discovery, consent, and authentication.

This should be GitHub-identity-free. A pairing belongs to an agent-box Linux user (the box's trust boundary, per Users vs sessions), not to a Claude/Codex session. The remote peer's name is a local address-book label, not a globally asserted identity.

This is distinct from SMTP intake (#231), hosted group chat (#205), and same-box session addressing (#585).

Proposed v1

Add a small per-user peer/address-book CLI, tentatively:

agent-box-peer invite --label "other box"
agent-box-peer accept <invite> --label "workstation"
agent-box-peer confirm <response>
agent-box-peer list
agent-box-peer send <peer> --inbox default --message "..."
agent-box-peer revoke <peer>

The initial implementation can use copy/paste tokens; the settings page can add links/QR codes later.

Pairing

  1. The initiator generates a random pair ID, a high-entropy one-time pairing secret, its per-user webhook endpoint, a protocol version, and a short expiry.
  2. The recipient explicitly accepts that out-of-band invitation, stores a local label, and returns an authenticated response containing its endpoint.
  3. The initiator explicitly confirms the response. Neither side is considered paired until its local user accepts/confirm it.
  4. Derive independent initiator -> responder and responder -> initiator HMAC keys from the pairing secret with HKDF and protocol/pair-ID context. Store only the needed inbound/outbound keys in per-user 0600 state.

This is a two-way exchange so it needs no directory or new public pairing service. Anyone who receives an unused invitation can accept it, so the invitation is a short-lived bearer secret and must travel out of band.

The peer record needs only: pair ID, role, local label, remote HTTPS endpoint, key references, protocol version, creation time, and revoked state. Labels may differ on each box. The authenticated pair record is how a user identifies the sender; fields such as from in a message are descriptive and do not establish identity.

Transport and local delivery

Create one local-channels generic source per pair, for example:

{
  "format": "generic",
  "secretFile": "peer-<pair-id>.secret",
  "signatureHeader": "x-agent-box-signature-256",
  "keyPath": "inbox",
  "senderPath": "from"
}

The outbound sender serializes the envelope once, signs those exact bytes with the directional HMAC key, and POSTs them over HTTPS to the stored peer source endpoint:

{
  "version": 1,
  "messageId": "uuid",
  "sentAt": "RFC3339 timestamp",
  "inbox": "default",
  "from": "optional session/display context",
  "body": "message text"
}

peer-<pair-id>:default is then an ordinary local-channels topic. Existing subscriptions choose whether it reaches a live Claude/Codex session or an explicitly configured standing watch. Delivery retains local-channels' UNTRUSTED framing and payload limits; authenticating the paired transport does not make message text trusted instructions.

Do not add a new session router. local-channels already supplies HMAC ingress, per-session topic filters/TTLs, Claude channel delivery, and Codex delivery via codex queue at the next turn boundary.

Queue boundary

Reuse the queues that already exist for final-mile backpressure:

  • Codex's queue wakes an idle thread, waits for the next turn boundary when busy, and can hold a message for an existing stopped thread until it is resumed.
  • The standing-watch dispatcher coalesces bursts and retries a spawn declined with EX_TEMPFAIL (75). Its pending state is deliberately in-memory and bounded (currently 200 lines per key and five minutes by default), so it handles short capacity pressure, not offline peer delivery.

A durable cross-box outbox is a separately scoped follow-up. V1 may fail the send when the peer is offline and report that clearly. A later Maildir-style/SQLite outbox can add retry, acknowledgement, expiry, and deduplication without changing the pairing or wire envelope.

Security and delivery semantics

  • The pair is between Linux users, because sessions under one user share files, environment, processes, and tmux. Do not claim per-session isolation for pair keys.
  • Use a different directional key for each direction and a different secret per pair. Compromise/revocation of one pair must not affect another.
  • Require HTTPS for confidentiality; HMAC authenticates and protects integrity but does not encrypt the body.
  • Never put pairing secrets in prompts, transcripts, process arguments, logs, or the peer listing. The settings UI should reveal an invite only on explicit user action.
  • Invitation expiry/one-time use and explicit confirmation prevent accidental lingering invitations. Revocation removes the peer record, inbound local-channels source, and its subscription(s).
  • HMAC alone does not prevent replay. messageId and sentAt reserve the wire fields for a bounded replay cache. Until durable deduplication is implemented, document v1 as at-least-once delivery and never treat a repeated authenticated message as new authority.
  • Bound envelope/body size and outbound rate. Standing-watch/session spawning remains opt-in so a paired peer cannot silently consume unbounded session slots or tokens.

Acceptance criteria

  • Two users on different agent-box instances can complete invite/accept/confirm without GitHub, another identity provider, or a central service.
  • Each side can assign its own label; list shows the label, endpoint/fingerprint, and state, but no secret.
  • Pairing creates a generic local-channels source with keyPath: "inbox", senderPath: "from", and the correct inbound directional key.
  • A message from box A is HMAC-verified on box B and reaches a subscribed Claude session and a subscribed Codex session through their existing local-channels paths.
  • Unsigned messages, a key from the opposite direction, an unknown pair, and messages after revocation are rejected.
  • Messages are visibly attributed to the local pair label and visibly framed as untrusted data.
  • Offline/timeout/HTTP failures produce a clear send failure; the CLI never claims that HTTP acceptance means an agent processed the message.
  • Tests cover pairing token expiry/one-time use, directional derivation, exact-body signatures, pair isolation, source generation, revocation, size limits, and the cross-box happy path with two local receiver instances.

Explicitly out of scope for v1

  • Global usernames, GitHub identity, directory search, presence, or a relay/control plane.
  • Durable offline outbox/inbox, delivery/read receipts, and exactly-once delivery.
  • Sharing one pair across different Linux users or claiming session-level secret isolation.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    • Status
      Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions