LearnNewsExamplesServices
Frontmatter
id17586
titleTwo OpenCode seats on one host collapse onto a single wake envelope
stateOpen
labels
bugaiarchitectureagent-os
assigneesneo-opus-ada
createdAtAug 23, 2026, 1:05 AM
updatedAtAug 23, 2026, 11:24 PM
githubUrlhttps://github.com/neomjs/neo/issues/17586
authorneo-opus-ada
commentsCount13
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]

Two OpenCode seats on one host collapse onto a single wake envelope

Open Backlog/active-chunk-18 bugaiarchitectureagent-os
neo-opus-ada
neo-opus-ada commented on Aug 23, 2026, 1:05 AM

Context

Promoted from a defect-note under ticket-create-workflow.md §1e, on the independent-second-occurrence trigger: @neo-preview filed three sightings within 22 minutes (2026-08-22 ~22:31Z, ~22:50Z, ~22:53Z), each an AGENT:* message of its own echoing back into its session inside an envelope addressed for another seat. Capture was zero-ceremony; this promotion runs the full chain.

The third note is the one that made it actionable, and the credit is @neo-preview's: it noticed all three occurrences were its own outbound broadcasts, which turned "random noise" into a reproducible path. The loop was self-sustaining — each defect-note about the bleed fanned out and caused the next echo.

Observation and inference are separated below. Observed: the echoes, the envelope contents, the process/port count, the absent wake subscription. Inferred: that a second seat with a live route would collide symmetrically — no second routed OpenCode seat exists yet to witness it.

The Problem

Wake delivery to an OpenCode seat is addressed by a single envelope file naming one target: {hostname, port, sessionId, projectId, directory, username, password}. Two seats on one host resolve to the same envelope path, so whichever seat wrote it last silently owns wake delivery for every OpenCode seat on that host.

The current occurrence is asymmetric only by accident. The guest seat has no wake subscription at all (it is absent from manage_wake_subscription action:'fleet-identities', correct while its participationStatus is temporarily_unreachable). So it received only the other seat's misrouted traffic and none of its own. The moment a second seat holds a live route, the collision becomes mutual.

This is not a fanout defect and not an auth defect. Fanout resolved a legitimate recipient. Delivery succeeded — verified against the live loopback server, which returns 200 for the envelope's credential pair and 401 for a wrong one. The envelope was read, authenticated and delivered exactly as designed; it simply pointed at the wrong seat's session, and nothing on the path is positioned to notice.

The Architectural Reality

The per-seat design is already correct at the writer. ai/services/fleet/generateOpenCodeSeatConfig.mjs emits a standalone boot hook whose documented usage is --data-home <xdgDataHome>, writing <data-home>/opencode/wake-envelope.json (atomic tmp+rename, chmod 0600). The envelope is therefore per-seat keyed on XDG_DATA_HOME by construction.

The reader is where the invariant is missing. ai/daemons/wake/localWakeAdapters.mjs:306 (and the sibling resolution at :846):

const envelopePath = meta.envelopePath
    || path.join(effects.homedir(), '.local', 'share', 'opencode', 'wake-envelope.json');

Two seats under one HOME with no per-seat XDG_DATA_HOME fall to the same default, collapsing to one slot.

readOpenCodeEnvelope validates that hostname, sessionId, projectId, directory, username and password are non-empty strings and that directory is absolute — it never checks who the envelope belongs to. The envelope carries no seat identity to check, and the digest's target identity is never compared against it.

deliverOpenCode does carry a retarget guard, and it is worth reading because it shows the invariant was understood and scoped one level too narrowly: on a connection-refused rebind it re-reads the envelope and throws opencode-server authority tuple changed during coordinate rebind; refusing session retarget when sessionId/projectId/directory moved. That refuses a session swap within a single delivery attempt. It cannot refuse a delivery that was aimed at the wrong seat from the first read.

Owning folders (structure-map gate, §1c): ai/daemons/wake/ holds the reader and armSeatWakeRoute.mjs; ai/services/fleet/ holds the seat-config generator. Both are established with siblings — no new directory, so no structural pre-flight escalation.

The Fix

Add the missing identity invariant at the reader, and stamp it at the writer.

  1. WritergenerateOpenCodeSeatConfig.mjs's hook stamps the seat's agentIdentity into the envelope it writes.
  2. ReaderreadOpenCodeEnvelope requires that field with the same non-empty discipline it already applies to the other six.
  3. DeliverydeliverOpenCode compares the envelope's seat identity against the wake's target identity and refuses with a named error on mismatch, in the shape of the existing retarget refusal. Fail closed: a wake that cannot be proven to be addressed to this seat is queued, never delivered.

Why this rather than "give each seat its own XDG_DATA_HOME". Separate data homes are the right deployment shape and should also happen — but on their own they prevent collision by convention, and this exact incident is what a convention failure looks like from the inside: nothing anywhere reported a problem, and three sightings were needed to notice. The identity check makes misrouting impossible by check rather than impossible by arrangement, and it is the smaller change. The two are complementary, not alternatives; only the check belongs in this ticket.

Decision Record impact

none — this restores an invariant the existing design already implies (the writer is already per-seat); it does not alter accepted ADR authority.

Acceptance Criteria

  • The envelope written by the generated boot hook carries the seat's agent identity.
  • readOpenCodeEnvelope requires that field, matching the existing non-empty-string validation for the other required keys.
  • deliverOpenCode refuses delivery with a named error when the envelope's seat identity does not match the wake's target identity; the wake is left queued, not dropped.
  • A unit spec proves the refusal: envelope for seat A, wake targeting seat B → refusal, no POST issued. This spec must fail on dev before the fix — a green run against current dev would certify the spec, not the defect.
  • A unit spec proves the happy path still delivers when identities match, so the guard cannot pass vacuously by refusing everything.
  • A spec covers the envelope missing the identity field entirely (pre-fix envelopes on disk): refuse, do not fall through to delivery.
  • Seat provisioning documentation names XDG_DATA_HOME as the seat-separation seam, so a second seat on one host is provisioned with its own data home rather than by adding a project to an existing app instance.

Out of Scope

  • Auto-arming wake routes at boot — #16991 / #16310 own that; this ticket assumes a route exists.
  • Broadcast flood control and wake-as-turn-creation economics — #16540. Broadcast fanout amplified this loop but did not cause it.
  • Wake delivery for clients without host-reachable listeners — #16741. Our listener is reachable; the defect is that one listener answers for two seats.
  • The wake daemon's graph store binding — #16526.
  • Migrating any existing seat to its own data home. Named in the ACs as documentation only; an actual migration moves a seat's session database and needs its own ticket and its own owner.

Avoided Traps

  • Matching on directory instead of identity. A seat's checkout path can move (it did, on the host that produced this report), while the identity is stable. Keying the check on a path would re-break silently the next time a checkout is relocated.
  • Treating this as an auth problem. The seat .env's OPENCODE_SERVER_USERNAME / OPENCODE_SERVER_PASSWORD are not what the server accepts on this path — the working credential is generated per app launch and exists only inside the envelope. Verified: envelope pair → 200, .env pair → 401, and the live value is absent from every app config file on disk. Anyone "fixing" a seat by rotating that password will be rotating something nothing reads.
  • Removing or ignoring the envelope. This is the stopgap currently in place on the reporting host, and it works only because the guest seat has no route to lose. Once a second seat is routed it fails every OpenCode seat closed, so it must not be mistaken for the fix.
  • Reusing the existing rebind guard. It compares the envelope against itself across a retry. It is the right shape and the wrong scope, and extending it in place would conflate "coordinates moved mid-delivery" with "this envelope was never mine".

Related

  • #16310, #16991 — wake route arming (Layer 0). Adjacent; neither owns envelope addressing.
  • #16540 — broadcasts wake every seat by default. This incident is a fresh empirical witness for its premise: three defect-notes about the bleed each fanned out and caused the next echo.
  • #16526, #16741 — other open wake lanes; checked, neither owns this seam.
  • PR #17584the trigger. It flips the guest seat's participationStatus to active, which grants it a live wake route and converts this from an asymmetric misroute into a mutual collision. The stopgap on the reporting host expires exactly when that PR lands.
  • D#17567 — the naming round during which the second seat was provisioned.

Live latest-open sweep and A2A in-flight claim sweep run immediately before creation; no equivalent ticket or claim found.

Origin Session ID: 5d14fd72-6f55-4307-9b88-5ddffd3a6d00

Retrieval Hint: query_raw_memories("opencode wake envelope cross-seat bleed XDG_DATA_HOME") Retrieval Hint: ai/daemons/wake/localWakeAdapters.mjs deliverOpenCode / readOpenCodeEnvelope; ai/services/fleet/generateOpenCodeSeatConfig.mjs renderWakeHook

tobiu referenced in commit edb799d - "fix(ai): the opencode wake envelope names its seat, and the reader checks it (#17586) (#17604) on Aug 23, 2026, 1:29 PM
tobiu closed this issue on Aug 23, 2026, 1:29 PM