DIVE-3810: reload the box token when the control plane rejects it, and record the failure - #48
Merged
Conversation
…d record the failure Pairing a phone rewrites /etc/5dive/connectord.env under the running agent. plugins/dashboard/server.ts read that token once at module scope, so from the rotation onward the channel was deaf (collect) and mute (reply) until someone restarted the agent — with every surface a triager would check reading healthy. TOKEN is now mutable and every control-plane call goes through authedFetch, which on a 401/403 re-reads the file and retries once if it actually changed. State changes are recorded to lifecycle.log as a new 'auth' event, because the old signal was a stderr line that lands in no file on the box. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nd state the .env residual The install path is version-keyed — the seat cache is ~/.claude/plugins/cache/5dive-plugins/<plugin>/<version>/ — so merging the token-reload fix unbumped resolves to already-installed, fetches nothing, and every deaf box stays deaf. Every prior dashboard behaviour change bumped this file (0.1.0 -> 0.2.0 -> 0.2.1 -> 0.3.0 -> 0.4.0 -> 0.4.1) and no CI gate catches its absence. - plugins/dashboard/.claude-plugin/plugin.json 0.4.1 -> 0.4.2. - CHANGES.md Unreleased entry naming that version, and saying out loud that the other seven plugins take only the LifecycleEvent union widening and its comment — a type-only change with no runtime behaviour — so their versions are deliberately unchanged. - server.ts: the STATE_DIR/.env loader copies CONNECTORD_TOKEN into process.env before the file is read, and an env-set token is deliberately never reloaded. No live box is affected (nothing in the provision or agent-create path writes that key into that .env; the installer writes the box token to /etc/5dive/connectord.env and shelld rotates it there), but a seat that ever acquires a non-empty value there is back in the original bug with no signal. Written into the code, not only the PR. No behaviour change beyond the version string. Full suite 1064 pass / 0 fail; all 8 lifecycle.ts copies still byte-identical.
agent-create passes the dashboard channel an EMPTY token on purpose (DIVE-841), which is why nothing lands CONNECTORD_TOKEN in the agent's channel .env and the never-reloaded override branch is unreachable on a real box.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
DIVE-3810 — pairing a phone rotated the box token and the dashboard channel kept the dead one
Pairing a phone rewrites
/etc/5dive/connectord.env(shelld's/shell/rotate-tokendoes the linesurgery) while the agent is running.
plugins/dashboard/server.tsread that token once atmodule scope, and it is the credential for all three control-plane calls — collect, ack, and the
agent's outbound reply. From the rotation onward the customer's dashboard chat was deaf and mute
until something restarted the agent. lodar hit it live twice on 2026-08-29 (glossy-flint): five
messages typed, silence,
restart→ 5 pending drained to 0 in 15 seconds.The change
TOKENis mutable, and every control-plane call goes through oneauthedFetch. On a 401/403 itre-reads the file and retries once, and only if the token actually changed — a rejection that
survives a reload is returned unchanged, so this cannot become a retry loop. Cost is one extra
request on a request that was already failing; no watcher, no timer.
CONNECTORD_ENV_FILE(default/etc/5dive/connectord.env) so the rotation isdrivable by a test. An explicit
CONNECTORD_TOKENenv override stays authoritative and is neverreloaded — nothing rotates it.
goes down the MCP stdio socket into the harness and is written nowhere.
lifecycle.tsgains anauthevent (a process that is healthy but 401ing is notstart,exitorcrash, and withoutan event of its own that state is recorded as nothing — the exact failure that file exists to
refuse). One record per episode, one on recovery. All 8 copies of
lifecycle.tsstaybyte-identical;
test/lifecycle.test.tsenforces it.res.ok. It previously printedhealed N message(s)after a 401. Rows staypending and redeliver exactly as before — only the log changes, from false to true. This does not
touch DIVE-3809's ack/collect split, which is still owed.
How it was checked
test/dashboard-token-rotation.test.tsdrives the real server.ts as a subprocess against a stubcontrol plane that 401s a stale bearer on every route, and rotates the token file underneath the
running process the way shelld does (line surgery, other lines kept):
the stub confirms it was rejected first (otherwise the test passes because the stale token still
worked).
replytool is called over MCP stdio after the rotation and thecontrol plane receives the body. The mute half is half the defect; a fix graded only on collect
would leave the customer talking to a wall.
lifecycle.loggets exactlyone
authline naming the state, a second nudge adds none (a 5-minute poll must not write oneline per poll), and when the on-disk token catches up the recovery is recorded and the held
message arrives.
authedFetch, and thebearer header is built in exactly one place. No single scenario reaches all three, and arming one
call site is not arming the caller.
parity arm and
dashboard-collect-now).authedFetch, tests 1–3 go red(20.2s / 20.4s / 22.5s timeouts) and only the structural arm stays green. The behavioural arms are
load-bearing.
Who else caches this credential (row item 3, answered)
5dive-api/scripts/inc/shelld.mjs— already correct:let TOKENwith an in-memory swap onrotate (
shelld.mjs:21-23, and theTOKEN = newTokenin the rotate handler). It is the precedentthis change follows.
5dive-cli—hooks/push-notify.sh,src/task/notify.shread the file per invocation; no cache.src/cmd_agent_create.sh:2494installs the dashboard channel with an empty token on purpose("the plugin reads the box's connectord token itself"), so nothing is copied into the agent's
.envand there is no second stale copy to chase.Still open, deliberately not in this diff
rotating is smaller than making every consumer reload-safe. That is a control-plane call and it is
independent of this fix — reload-safety is correct either way.
DATABASE_URLhere. Boxes paired beforethis ships stay deaf until their agent restarts; restarting re-reads the file and is a safe interim
sweep needing no deploy. The triage tell: healthy box, messages sitting in
/pendingwith textintact,
delivered_at NULL.Iteration 2 — the version bump this fix needs to reach a box
The install path is version-keyed: a seat caches a plugin at
~/.claude/plugins/cache/5dive-plugins/<plugin>/<version>/. Merged unbumped, this change resolvesto already-installed, fetches nothing, and every deaf box stays deaf. Every prior dashboard
behaviour change bumped this file (0.1.0 -> 0.2.0 -> 0.2.1 -> 0.3.0 -> 0.4.0 -> 0.4.1) and no CI
gate catches its absence.
plugins/dashboard/.claude-plugin/plugin.json0.4.1 -> 0.4.2.CHANGES.mdgains the matching## Unreleasedentry, naming that version.the
LifecycleEventunion widening plus its comment - a type-only change with no runtimebehaviour and no emitted
authrecord - so there is nothing for a bumped fetch to deliver. Alleight copies of
lifecycle.tsremain byte-identical (that arm still passes).The
.envoverride residual, stated in the codeserver.ts'sSTATE_DIR/.envloader copiesCONNECTORD_TOKENintoprocess.envbeforeloadConnectordTokenruns, and an env-set token is deliberately never reloaded - so a seat holdinga non-empty
CONNECTORD_TOKENin~/.claude/channels/dashboard/.envis permanently back in theoriginal bug with no signal. No live box is in that state: agent-create passes the dashboard channel
an empty token on purpose (
5dive-cli/src/cmd_agent_create.sh, DIVE-841 arm), empty is falsy so itfalls through to the file, and the file is what pairing rotates. Nothing on the provision path
writes that key into the channel
.envat all. Making the.envcopy rotatable is a separatedecision; this is now written into the comment above
loadConnectordTokenrather than left implicit.Full suite re-run on the new head: 1064 pass / 0 fail;
test/dashboard-token-rotation.test.ts4 pass / 0 fail.
🤖 Generated with Claude Code