v1.23.0's consumer-enrollment ceremony polls an expired request indefinitely, from every OpenCode process on the host, and survives restarts. Nothing is logged on our side, so the vault operator found it before we did.
Observed on a host running handle/manifest custody (claustrum.mode: "claustrum", no primaryAccount). Nobody ran setup or asked to enroll.
What happened
| time (UTC) |
event |
| 09-22 19:40:47 |
first boot on v1.23.0 creates opencode-enrollment-state.json (phase: pending, no requestId) |
| 09-22 19:40 → 09-23 06:42 |
every process retries the propose against a Claustrum build that does not serve auth.enroll_propose |
| 09-23 06:42:06 |
new Claustrum build accepts it; requestId written (vault: auth.enroll_propose seq 336) |
| 06:42 → 06:57 |
~2.2 polls/s, all pending (~2,000 polls) |
| 06:57:06 |
request expires on schedule (auth.enroll_expire) |
| 06:57 → now |
every poll answers superseded; polling continues at ~22/min |
Only one propose was ever sent. The polling is the problem.
Causes, from source
1. Trigger is config state at plugin init, not an explicit action. packages/opencode/src/index.ts:1283-1287:
if (isScopedCustodyActive(initialStorage)) {
getOpenCodeScopedRuntime(accountStoragePath, ctx.directory).start()
} else if (getClaustrumMode(initialStorage) === 'claustrum') {
ensureClaustrumEnrollmentAdoption()
}
Any handle-custody install that hasn't finished the zero-bind upgrade takes the second arm on every boot. The status projection at :5613 re-arms it too. I understand this is how setup upgrades an install, but it fires whether or not the operator has started that upgrade.
2. superseded never becomes terminal. The manager has two terminal outcomes: denied, and blocked for non-retry error classes. A superseded reply comes back with a retryable class, so reconcile returns pending with a retryCode, and the registry reschedules at RETRY_POLL_MS (60 s). The on-disk state stays pending.
3. Restarting doesn't stop it. The requestId is persisted. A fresh process reads phase: pending plus the requestId, skips propose, and polls the dead id again. Every future process inherits the loop.
4. Cadence scales with process count. PENDING_POLL_MS = 5_000, one registry per process, no attempt cap. With 11 OpenCode processes that is 11 × 0.2/s = 2.2/s against one pending request. A developer with many sessions open multiplies the load on the vault's single writer.
5. The ceremony is silent. onStatus logs only transitions into unavailable or approved. A pending with a retryCode covers the whole failure: 11 h of rejected proposes, ~2,000 pending polls, and the superseded loop. It produced zero lines in /tmp/opencode-anthropic-auth.log across four rotated generations. The vault found this from its own audit rows.
Suggested fix
- Treat
superseded and expired as terminal for that request id. Either re-propose once with a fresh secret or persist a terminal phase that /claude-account enrollment-reset can clear. Never retry a dead id.
- Don't start the ceremony on boot for a handle-custody install. Tie it to
setup or an explicit command, or at least to an opt-in config flag.
- Make the poll cadence independent of process count. One poller per host (the ceremony lock already exists), or back off exponentially with a cap.
- Log every ceremony state transition, including
pending → pending when the retryCode changes.
Cost today is bounded: each poll writes one row into a 64-row ring for the poll key, and no credential ring is touched. But the loop never ends on its own, and it grows with every process a user opens.
Local state
~/.local/state/cortexkit/anthropic-auth/opencode-enrollment-state.json (0600): phase: pending, requestId set. Deleting it would mint a new secret and re-propose, which starts a fresh 5 s × N-process storm and then the same superseded loop 15 minutes later. enrollment-reset refuses because the phase is pending. There is no supported way to stop the loop short of editing the state file by hand.
v1.23.0's consumer-enrollment ceremony polls an expired request indefinitely, from every OpenCode process on the host, and survives restarts. Nothing is logged on our side, so the vault operator found it before we did.
Observed on a host running handle/manifest custody (
claustrum.mode: "claustrum", noprimaryAccount). Nobody ransetupor asked to enroll.What happened
opencode-enrollment-state.json(phase: pending, norequestId)auth.enroll_proposerequestIdwritten (vault:auth.enroll_proposeseq 336)pending(~2,000 polls)auth.enroll_expire)superseded; polling continues at ~22/minOnly one propose was ever sent. The polling is the problem.
Causes, from source
1. Trigger is config state at plugin init, not an explicit action.
packages/opencode/src/index.ts:1283-1287:Any handle-custody install that hasn't finished the zero-bind upgrade takes the second arm on every boot. The status projection at
:5613re-arms it too. I understand this is howsetupupgrades an install, but it fires whether or not the operator has started that upgrade.2.
supersedednever becomes terminal. The manager has two terminal outcomes:denied, andblockedfor non-retry error classes. Asupersededreply comes back with a retryable class, soreconcilereturnspendingwith aretryCode, and the registry reschedules atRETRY_POLL_MS(60 s). The on-disk state stayspending.3. Restarting doesn't stop it. The
requestIdis persisted. A fresh process readsphase: pendingplus therequestId, skips propose, and polls the dead id again. Every future process inherits the loop.4. Cadence scales with process count.
PENDING_POLL_MS = 5_000, one registry per process, no attempt cap. With 11 OpenCode processes that is 11 × 0.2/s = 2.2/s against one pending request. A developer with many sessions open multiplies the load on the vault's single writer.5. The ceremony is silent.
onStatuslogs only transitions intounavailableorapproved. Apendingwith aretryCodecovers the whole failure: 11 h of rejected proposes, ~2,000 pending polls, and the superseded loop. It produced zero lines in/tmp/opencode-anthropic-auth.logacross four rotated generations. The vault found this from its own audit rows.Suggested fix
supersededandexpiredas terminal for that request id. Either re-propose once with a fresh secret or persist a terminal phase that/claude-account enrollment-resetcan clear. Never retry a dead id.setupor an explicit command, or at least to an opt-in config flag.pending→pendingwhen theretryCodechanges.Cost today is bounded: each poll writes one row into a 64-row ring for the poll key, and no credential ring is touched. But the loop never ends on its own, and it grows with every process a user opens.
Local state
~/.local/state/cortexkit/anthropic-auth/opencode-enrollment-state.json(0600):phase: pending,requestIdset. Deleting it would mint a new secret and re-propose, which starts a fresh 5 s × N-process storm and then the same superseded loop 15 minutes later.enrollment-resetrefuses because the phase ispending. There is no supported way to stop the loop short of editing the state file by hand.