feat(selfhost): rotate subscription CLI credentials at runtime without a restart - #9548
Conversation
|
Tip ✅ LoopOver review result - approve/merge recommendedReview updated: 2026-07-28 08:21:15 UTC
Review summary Nits — 7 non-blocking
Decision drivers
Context & advisory signals — never blocks the verdict
Linked issue satisfactionAddressed Review context
Contributor next steps
Signal definitions
🧪 Chat with LoopOverAsk LoopOver a question about this PR directly in a comment — grounded only in the same cached, public-safe facts shown above, never a new claim.
Full command reference: https://loopover.ai/docs/loopover-commands 🧪 Experimental — new and may change. Decision record
Visual preview
Click any thumbnail to open the full-size screenshot. Before = production · After = this PR's preview deploy. Scroll preview
A short scroll-through clip (desktop) — click either thumbnail to open the full animation. Evidence for scroll-linked behavior a single screenshot can't show. 🟩 Safe / merged · 🟦 Advisory · 🟨 Held for review · 🟥 Blocked / closed 💰 Earn for open-source contributions like this. Gittensor lets GitHub contributors earn for the work they already do — register to start earning →. Checked by LoopOver, a quiet PR intelligence layer for OSS maintainers.
|
|
Superagent didn't find any vulnerabilities or security issues in this PR. |
31500b4 to
c569108
Compare
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
loopover-ui | 755ee9e | Commit Preview URL Branch Preview URL |
Jul 28 2026, 08:04 AM |
Bundle ReportChanges will increase total bundle size by 8.26kB (0.11%) ⬆️. This is within the configured threshold ✅ Detailed changes
Affected Assets, Files, and Routes:view changes for bundle: loopover-uiAssets Changed:
|
…t a restart Resolve the Claude OAuth token at AI-call time instead of freezing it into process.env at boot, and add three write surfaces for rotating it safely. Resolution order (highest first): a DB-backed fleet credential, a fresh read of CLAUDE_CODE_OAUTH_TOKEN_FILE, then the boot env value. The file is only re-read when the boot loader actually sourced the value from it, so the documented 'an inline .env value always wins' precedence is preserved; every failure degrades to the next rung rather than failing a review. Write surfaces: - scripts/rotate-secret.sh for a single box, validating shape and writing in place so the container's inode-pinned bind mount sees the change immediately - a rotate-secret verb on the redeploy companion, plus a loopover_admin_rotate_secret MCP admin tool, since the app container cannot write its own secrets (the mount is read-only) - INTERNAL_JOB_TOKEN-gated /v1/internal/provider-credentials/* backed by a new provider_credentials table, encrypted with the existing BYOK AES-256-GCM envelope, for fleets with no shared filesystem This closes two silent failure modes seen in production: a label line above the value became part of the credential (the loader only trims), and a write-new-then-rename left the running container serving the old value while still reporting healthy. Closes #9543
c569108 to
755ee9e
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #9548 +/- ##
==========================================
+ Coverage 89.58% 89.59% +0.01%
==========================================
Files 846 848 +2
Lines 110384 110501 +117
Branches 26295 26325 +30
==========================================
+ Hits 98889 99006 +117
Misses 10231 10231
Partials 1264 1264
Flags with carried forward coverage won't be shown. Click here to find out more.
|


Summary
Rotating a subscription-CLI credential on an ORB host was a manual, multi-step process with two failure modes that are both silent — the container stays healthy and the status stays green while every review quietly degrades to the fallback provider. Both were hit for real on the hosted box:
loadFileSecretsonly.trim()s the file — it does not strip comments or select a line. A human-added label line above the value became part of the credential.secrets:entry is a bind mount pinned to the inode. Writing in place propagates instantly; write-new-then-rename leaves the container serving the old bytes, anddocker compose up -ddoes not repair it (it printsContainer Runningand changes nothing when the config is unchanged).On top of that, rotating required a container recreate at all — dropping in-flight reviews for a credential swap that should be a no-op.
Call-time resolution
createClaudeCodeAinow resolves the token per call instead of reading a value frozen intoprocess.envat boot, mirroring how codex'sauth.jsonis already re-resolved per call. Precedence, highest first:CLAUDE_CODE_OAUTH_TOKEN_FILE— but only when the boot loader actually sourced the live value from that file.docker-compose.ymlsets<NAME>_FILEunconditionally, so its mere presence proves nothing; an operator using an inline.envvalue has both set, and re-reading the file for them would silently swap their credential.loadFileSecretsnow returns which vars it materialised, which is the only signal that separates the two cases — so the documented "an inline.envvalue always wins" precedence is preserved exactly.Every failure degrades to the next rung rather than throwing: a half-written file caught mid-rotation, or a DB blip, must not fail a review the previous credential could still have served.
Write surfaces
Both deployment shapes are covered, because both are in use:
scripts/rotate-secret.sh: validates shape, backs up, writes in place, and verifies the running container's own view matches before reporting success.rotate-secretverb on the redeploy companion, plus aloopover_admin_rotate_secretMCP admin tool. This lives host-side for a hard reason:docker inspectreports the secrets mount asrw=false, so a container-side rotation endpoint is impossible, not merely undesirable. An allowlist of rotatable names keeps it from becoming a general "write any file as me" primitive.POST/GET/DELETE /v1/internal/provider-credentials/:provider, backed by a newprovider_credentialstable reusing the existing BYOK AES-256-GCM envelope.GETnever returns the credential, onlyconfigured/last4/timestamps. A fleet has no shared filesystem for the file path to use.For
claude_code_oauth_tokenno restart is needed at all now. Codex was already hot (itsauth.jsonis re-resolved per call) — the script and companion cover it, and its docs are corrected.Scoping note
The fleet (DB) path is wired for
claude-code. Codex's fleet story stays host-side (per-host companion) rather than DB-backed, because materialising a DB credential for codex would mean writingauth.jsonper call and interacting withassertCodexCredentialIsolation, a deliberate fail-closed invariant. Weakening that to save a config step is a bad trade; the table and registry are provider-generic so it remains a small follow-up if wanted.Validation
npm run typecheck— clean (0 errors)npm run test:changed— 476 files, 11,486 tests pass, no regressionsnpm run db:migrations:check— contiguous0001..0196npm run db:schema-drift:check— schema matches migrationsnpm run ui:openapi:check/npm run selfhost:env-reference:check— no driftbash -n+shellcheckclean on the new scriptsrc/**lines (the one line isrepositoryLinearKeys' pre-existingupdatedAtdefault, pulled into the diff by hunk alignment, not new code)Coverage was verified by cross-referencing the diff's changed line numbers against a JSON coverage report, not by reading a summary percentage. Both sides of every new
??/ternary/&&are exercised, including: the resolver's five fallback rungs, an empty file mid-rotation, an unreadable file, a throwing resolver, a rotation refusal carrying no error message, and a non-Errorrejection.Two things the tests pin explicitly:
actionstill redeploys — the app container and the host companion upgrade independently, so a new companion must keep serving an old client.One piece of dead code was found and removed rather than tested around: the route's
empty_credentialcatch was unreachable, since the zod schema already rejects an empty credential with a 400. The repository-level guard stays for direct callers.Safety
GETreturnsconfigured/last4only; a test asserts the response body does not contain the credential.mcpactor and a session identity are both rejected from the admin tool; the internal routes 401 withoutINTERNAL_JOB_TOKEN; an unexpected storage failure propagates as a 500 rather than being mislabelled a credential error.POSTreturns 503 rather than storing anything in the clear whenTOKEN_ENCRYPTION_SECRETis unset.LOOPOVER_MCP_ADMIN_ENABLED(registration) andmcp-adminactor (call time), unchanged from the existing admin tools.Note
claude_code_error_429is a quota signal, not an auth signal — rotating a credential does not clear it. Keeping the two visibly distinct is filed in the issue as follow-up, not done here.Closes #9543