feat(pi): renew Pi credentials on the host before they expire - #251
Merged
Conversation
Every Pi profile in the fleet expired on the same day and nothing renewed one. fm-credential-expiry.py is the detector and stops at the actuator by design, so a profile classified refreshable stayed refreshable until a human opened a browser. A reviewer compartment cannot renew its own credential either: its egress allowlist carries the provider's API host and deliberately not the provider's auth host. Renewal has to happen on the host and be staged outward. fm-pi-refresh.py selects the profiles whose access token dies inside a horizon, copies the pool, hands the due slots to fm-pi-refresh.mjs, republishes each renewed slot into the account home its consumers read, and re-reads that home through the expiry owner before reporting success. Its exit code therefore means the fleet is live, not that an HTTP call returned 200. The rotation runs in Node because only half of it is the HTTP request. The other half is the write-back, which must land under the lock Pi takes or a running Pi overwrites it, and because the refresh has to happen inside that same lock or two refreshers spend one refresh token and the provider invalidates the loser. Driving Pi's own AuthStorage uses that protocol instead of re-deriving it. The prior art at ~/.pi/agent/fm-patches/reauth.sh writes the pool without the lock and its own header warns about exactly this. Details worth knowing: - The horizon defaults to half the observed ten-day credential life, so the machine has to be off five consecutive days before a token is lost. - The copy is taken before anything rotates because Pi rewrites the credential file with a truncating write rather than a rename, so an interrupted write loses every slot in the file rather than the one being renewed. - A Node below the floor the Pi install declares is refused by version. This machine carries two Pi installs and the older one's sibling Node dies inside undici with a message that reads like a provider failure. - A provider error is redacted before it is reported. Pi's refresh error text interpolates the provider's JSON response, and one failure shape of that response carries an access token. - A rotation is decided by a changed access-token digest, not by the store returning a credential: modify returns the stored credential unchanged when its callback declines, which is indistinguishable from success otherwise. fm-credential-expiry.py's docstring and two of its detail strings said firstmate has no token refresh anywhere. That is now false for Pi, so they say what is still true instead: refreshable still means not usable where the caller runs, and a caller that needs a live credential asks for usable. Verified on the real fleet: all eight profiles renewed from 2026-08-25 to 2026-08-29, republished, and read back through fm-credential-expiry.py and through fm-crosscheck.py's own reader and identity, still eight distinct accounts. The behavior test drives the adapter against Pi's real credential store with only the rotation stubbed, so the lock, the read-modify-write and the on-disk result are the production mechanism rather than a description of it. It found two defects before this landed: a second-resolution backup name that collided into an unhandled traceback, and a missing OSError refusal contract. Size note: about 1300 added lines across an entrypoint, an actuator and a behavior test. The scheduler that makes this unattended is a separate change.
added 2 commits
August 19, 2026 00:28
… defects An adversarial review found that the single most valuable test in this change did not run on the machine that gates merges. The adapter contract skipped whenever Pi was absent, Pi is never installed on the CI runner, and the run still exited 0. Two mutations of the actuator, the rotation decision and the token redaction, passed CI green. Confirmed in the job log for f991c62: "skip: pi is not installed for the adapter contract", exit=0. The test is now two units. The outcome contract drives the real refreshSlots with a store that keeps modify's contract and needs nothing but Node, because what it pins is the adapter's own classification. The store contract drives Pi's real credential store and covers the lock, the read-modify-write and what lands on disk. CI installs Pi and sets FM_PI_REQUIRED, so a failed install is a red test rather than a silent skip. Verified with Pi removed from PATH entirely: three mutations, including both that used to survive, now go red. bin/fm-lint-node.sh parses the .mjs and .cjs tools in bin/. ShellCheck's file set is shell only, so a syntax error in the repo's JavaScript tools reached main with a green lint. Five defects the same review found and demonstrated: A held lock made a live credential report as absent. AuthStorage.read gives up after Pi's 30s lock deadline and swallows the failure over an empty snapshot, so a slot merely held by a running Pi read as having no credential at all. The optimistic read is gone; everything the outcome depends on is now read inside the lock, which also removes the read-to-modify window. A rotation the provider made but the store could not keep was reported as an ordinary failure. It is not: the host is left holding a token the provider has retired, and restoring the pre-renewal copy restores that same dead token. It now reports rotated-unpersisted and says the profile needs an interactive login. The adapter time budget counted only the HTTPS round trip, so eight slots got 190s against a 400s worst case, and an overrun SIGKILLed Node possibly mid write. That is the interrupted write the copy exists to survive, self inflicted. The budget now includes Pi's 30s lock deadline per slot. Backups were pruned only on the success path, so a recurring failure left one more full copy of every credential in the fleet at rest per scheduled run. They are pruned as soon as the pool is proved intact. The Codex flow was applied to any slot handed to it. The only thing stopping an Anthropic credential's refresh token from being posted to OpenAI's token endpoint was that Anthropic credentials carry no accountId and a different tool happened to require one. That is now a gate in the adapter with its own outcome. Also: Python redacts adapter stderr on its own side of the process boundary rather than trusting a slice; the pre-renewal copy is proved to be a copy before it is offered as one; each candidate Node is probed once rather than twice, so a hanging Node costs one timeout; the Pi package is identified by name rather than by having any package.json; FM_PI_BIN naming a file without the execute bit says so instead of "not on PATH"; and --all together with --slot refuses instead of silently ignoring the slots.
Every behavior shard failed on the previous head, and the install was not the
reason: the step that verified it was. `require("@earendil-works/...")` does
not resolve from npm's global prefix, so the verification threw and took the
whole job with it before a single test ran.
It now resolves the package by absolute path from `npm root -g`, asserts the
one module the adapter actually imports is there, and exports
FM_PI_PACKAGE_DIR so the store contract finds it without a PATH lookup for a
binary it never runs.
Proved locally by running the same resolution and then running the suite with
Pi removed from PATH entirely, reaching it only through FM_PI_PACKAGE_DIR.
This was referenced Aug 19, 2026
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.
Why
Every Pi profile in the fleet expired on 2026-08-25 and nothing renewed one.
bin/fm-credential-expiry.pyis the detector and stops at the actuator by design, so a profile classifiedrefreshablestayedrefreshableuntil a human opened a browser.A reviewer compartment cannot renew its own credential: its egress allowlist carries the provider's API host and deliberately not the provider's auth host, so a CLI inside the compartment can never reach a token endpoint. Renewal has to happen on the host and be staged outward.
What lands
bin/fm-pi-refresh.pyselects the profiles whose access token dies inside a horizon, copies the pool, hands the due slots tobin/fm-pi-refresh.mjs, republishes each renewed slot into the account home its consumers read, and re-reads that home through the expiry owner before reporting success. Its exit code means the fleet is live, not that an HTTP call returned 200.Why the rotation is in Node
Only half of the rotation is the HTTP request. The other half is the write-back, which must land under the lock Pi takes or a running Pi overwrites it, and the refresh has to happen inside that same lock or two refreshers spend one refresh token and the provider invalidates the loser. Driving Pi's own
AuthStorageuses that protocol rather than re-deriving it in another language. The prior art at~/.pi/agent/fm-patches/reauth.shwrites the pool without the lock, and its own header warns about exactly this.Both modules are imported by absolute path because Pi's
exportsmap publishes neither, and the package barrel does not exportAuthStorageand pulls in the whole TUI.Decisions worth reviewing
modifyreturns the stored credential unchanged when its callback declines, which is otherwise indistinguishable from success.bin/fm-credential-expiry.py's docstring and two of itsdetailstrings said firstmate has no token refresh anywhere. That is now false for Pi, so they say what is still true:refreshablestill means not usable where the caller runs, and a caller that needs a live credential asks forusable.Verification
Run against the real fleet: all eight profiles renewed from 2026-08-25 to 2026-08-29, republished, and read back through
bin/fm-credential-expiry.py(eightusable) and throughbin/fm-crosscheck.py's owninspect_pi_credentialandaccount_identity(eight distinct accounts, unchanged).tests/fm-pi-refresh.test.sh(hermetic, registered in both inventories, run throughtests/run.sh) drives the adapter against Pi's real credential store with only the rotation stubbed, so the lock, the read-modify-write and the on-disk result are the production mechanism rather than a description of it. It asserts the rotated case, the unrotated case, an absent slot, an account that drifts, and a throwing refresh whose token-shaped text must come back redacted. The CLI half runs against a fixture Pi install and a stubbed Node, and proves thatreportnever invokes the adapter at all.The test found two defects before this landed: a second-resolution backup name that collided into an unhandled traceback, and a missing
OSErrorrefusal contract.bin/fm-behavior-shards.sh --check 8passes.tests/fm-credential-expiry.test.shandtests/fm-pi-account-home.test.shstill pass.Not in this change
The LaunchAgent that makes this unattended is a separate change, so this one stays reviewable on its own.