fix(state): token rotation honors PILOT_PROJECT_STATE and never fails silently#34
Merged
Conversation
…ail silently Second-wave audit finding (#33 Tier-1), verified by hand + convergent across two independent review passes. updateStateToken had two defects: - Called writeState with the default mode="auto", ignoring the user's PILOT_PROJECT_STATE. With `off` + an existing .opencode/, a token rotation could create the very project pilot-state.json the user opted out of. - `if (existing)` with no else/log: when readState returned null (state file deleted/unreadable mid-session) rotation returned 200, mutated the in-memory token and broadcast SSE, but the persisted state kept the OLD token — TUI /remote then served a dead URL. Invisible, violating AGENTS.md "No silent failures". Fix (keeps core/ pure — observability at the transport boundary): - updateStateToken(dir, token, mode = "auto"): UpdateTokenResult, threads mode into writeState; returns a typed discriminated result ({ ok:true, write: WriteStateResult } | { ok:false, reason }). - rotateAuthToken passes deps.config.projectStateMode and inspects the result: on no-existing-state OR a global write failure it emits deps.logger.warn + audit "auth.token.rotated.persist_failed". HTTP stays 200 (in-memory rotation + SSE are the real success path) but the persistence gap is now observable. +10 deterministic tests (XDG_STATE_HOME-isolated so the readState→null path is forced, not shape-only). bun test 634/0, tsc clean. Independent fresh-context review: APPROVE, no blocking issues. Refs #33
This was referenced May 17, 2026
11 tasks
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.
Summary
Resolves the Tier-1 state/token item of #33 (second-wave audit). Verified by hand and convergent across two independent review passes (domain-logic + silent-failures).
updateStateToken(core/state/store.ts) had two defects:writeStatewith the defaultmode="auto", ignoringPILOT_PROJECT_STATE. Withoff+ an existing.opencode/, a token rotation could create the projectpilot-state.jsonthe user explicitly opted out of.if (existing)with no else/log. WhenreadStatereturnednull(state file deleted/unreadable mid-session), rotation returned 200, updated the in-memory token, broadcast SSE — but the persisted state kept the old token, so the TUI/remotelater served a dead URL. Invisible, violating AGENTS.md §"No silent failures".Fix (core/ stays pure — observability at the boundary)
updateStateToken(dir, token, mode = "auto"): UpdateTokenResult— threadsmodeintowriteState; returns a typed discriminated result{ ok:true; write: WriteStateResult } | { ok:false; reason:"no-existing-state" }. No logger/HTTP import added tocore/.rotateAuthTokenpassesdeps.config.projectStateModeand inspects the result. Onno-existing-stateor a global write failure it emitsdeps.logger.warn(...)+deps.audit.log("auth.token.rotated.persist_failed", { reason }). HTTP stays 200 (in-memory rotation + SSE are the real success path) — the persistence gap is now observable, not silent.Testing
XDG_STATE_HOMEto a fresh temp dir (verified that's the env governingglobalStatePath(), notXDG_CONFIG_HOME) soreadState→null is forced and asserted unconditionally — not shape-only. Env restored inafterEach, no leak (other 624 tests unaffected).bun test634/0 ·bunx tsc --noEmitcleanKnown follow-up (acknowledged, not blocking): the
global-write-failedbranch lacks direct coverage (needs FS-failure injection) — noted for a future test-hardening pass.Refs #33 (Tier-1, partial — does not close; #33 tracks 6 PR groups).