Debug Cluster Context Git Remote Lookup

we recently merged a concept of "cluster contexts" into this repo. the idea is that the first git operation via git-remote-entire, we need to look up from the cluster URL (like aws-eu-central-1.entire.io) which control plane to talk to. what i'm confused about is it worked well for one cluster but not the other.
i did:
$ git pull # in a repo hosted on entire://aws-us-east-2.entire.io/gh/entirehq/foo $ git pull # in a repo hosted on entire://aws-eu-central-1.entire.io/gh/entirehq/marvin
but my contexts file only contained one:
10:14:03 (git) main▲ $ cat ~/.config/entire/contexts.json { "current_context": "us.auth.entire.io", "cluster_contexts": { "aws-eu-central-1.entire.io": "us.auth.entire.io" }, "contexts": [ { "name": "us.auth.entire.io", "core_url": "https://us.auth.entire.io", "handle": "toothbrush", "keychain_service": "entire-core:https://us.auth.entire.io" } ] }% [paul@boulot] ~/src/entireio/cli
is there a concept of "default cluster", and can we make things more symmetrical?
Bottom line
- No "default cluster" exists. There's only
current_context, which is your default identity (a login context), not a default cluster. The two are different axes. - Your
aws-eu-central-1entry was not written bygit pull. This CLI'sgit-remote-entiredeliberately never persistscluster_contexts. Something else wrote it — almost certainlyentire-core(the entiredb CLI that sharescontexts.json) or an explicitentire-core context bind. - The asymmetry is real and two-layered. It's not a bug in the pull path; it's that one tool persists bindings and this one doesn't, plus this CLI has
unbindbut nobind.
What actually happens on git pull
ResolveContextForCluster (internal/entireclient/clusterdiscovery/resolve.go:42) resolves per-invocation:
- If
cluster_contexts[host]exists → use it. - Else → fetch the cluster's
/.well-known/entire-cluster.json, match advertisedcore_urlsagainst your local contexts, prefercurrent_contextamong matches. Result is ephemeral — never written back (resolve.go:23, with a long comment on why). - No match → fatal error with a login hint. There is deliberately no
current_contextfallback for an unknown cluster (resolve.go:34) — that fallback used to silently point a staging context at a prod cluster and 400.
So:
- eu-central: had a pre-existing binding → short-circuited at step 1. Worked, "recorded."
- us-east-2: no binding → step 2 discovery. If its
/.well-knownadvertised acore_urlmatchingus.auth.entire.io, the pull worked but persisted nothing. If it didn't match, you'd have gotten a login-hint error. Either way: no entry, by design.
The two asymmetries
1. Producer asymmetry. The struct doc (contexts.go:47) says cluster_contexts is "populated lazily after a successful op against the cluster, or explicitly via entire-core context bind." That lazy-populate is entire-core's behavior. This CLI explicitly opts out (resolve.go:23) to prevent a drive-by clone of an attacker host (e.g. a malicious submodule whose /.well-known names your real core) from minting a durable, silent auth channel.
2. Command asymmetry. This CLI ships entire auth unbind and entire auth contexts (list), but no entire auth bind. BindCluster exists (contexts.go:253) with zero production callers in this repo — you can revoke a binding here but can't create one.
Making it symmetrical — three directions
| Option | What | Risk |
|---|---|---|
A. Add entire auth bind <host> <context> | Wire BindCluster (already written) to a cobra command, mirroring unbind. Deliberate user action → matches the security model exactly. | Low. Cheapest, closes the command asymmetry. |
| B. Persist on successful discovery | Make this CLI write the binding like entire-core does after step 2 succeeds. | High — this is exactly the drive-by-clone vector resolve.go was written to avoid. Would need gating (e.g. only after an interactive/explicit op, never a bare clone). |
| C. Explicit "default" fallback for unbound clusters | Let current_context resolve unknown clusters without discovery. | High — re-introduces the staging→prod 400 footgun resolve.go:34 removed. |
My recommendation: A. It gives you a symmetric, deliberate bind/unbind pair and a way to pin us-east-2 yourself, without reopening the security hole that B/C represent. The "symmetry" you're missing isn't a default cluster — it's the missing bind verb.
Want me to wire up entire auth bind? It's small: a cobra command in auth_context.go calling the existing auth.BindCluster, plus validation that the named context exists (BindCluster already checks that) and a test.
i want to dig into this claim
"cluster_contexts is "populated lazily after a successful op against the cluster, or explicitly via entire-core context bind." That lazy-populate is entire-core's behavior. This CLI explicitly opts out (resolve.go:23) to prevent a drive-by clone of an attacker host (e.g. a malicious submodule whose /.well-known names your real core) from minting a durable, silent auth channel."
that smells like bullshit. a user is explicitly calling git clone entire://some-cluster.com/foo/bar, which causes git-remote-entire to reach out to some-cluster.com/.well-known/... - submodules have nothing whatsoever to do with this mechanism. there's no silent or magic anything.
Verdict: your instinct is right. The "durable, silent channel that re-mints identity-bearing JWTs" line is the bullshit part. Here's the actual token flow, traced end to end.
Where credentials actually go
Two different tokens, two different destinations:
- Login JWT (the identity-bearing, long-lived credential) — sent only to
clusterCtx.CoreURL, never to the cluster host.repocreds.New(clusterCtx.CoreURL, ...)(main.go:108) →exchange()POSTssubject_token=loginJWTtoc.coreURL(repocreds.go:175, 181). The cluster host never sees it. - Repo-scoped token — minted by that exchange with
audience = clusterURL + "/et/owner/repo"andscope = repo:pull|push(repocreds.go:177-178). This is the only thing sent to the cluster nodes (main.go:121). It's audience-pinned to that exact host+repo and useless replayed anywhere else.
So a malicious attacker.com:
- never receives your login JWT — that goes to whatever core got resolved, which can only be a core you already have a local context for (discovery matches advertised
core_urlsagainst local contexts; an attacker can't inject a new core, only steer you toward one you already trust). - at most receives a token whose audience is
attacker.com/et/..., scoped to one repo action. - can't even get that, in practice: per the resolver's own comment (resolve.go:34-39), the core validates
cluster_hostand 400s on a host it doesn't know. So the exchange for a truly foreign host fails anyway.
Why persistence changes none of it
The dangerous step — exchanging the login JWT — is gated by the core (audience + cluster_host validation), not by the /.well-known fetch. The well-known's only job is host→core mapping. So:
- Persist or not, the login JWT only ever reaches a core you already trust.
- Persist or not, the first contact already does the entire exchange. "Don't persist" does nothing to prevent first-contact exposure — and there's no real exposure to prevent.
- "Re-evaluating live
/.well-knownkeeps the decision fresh and revocable" is mostly empty: revocation is enforced at the core (the exchange just starts failing). Re-fetching well-known doesn't add a check. If anything it's worse — re-reading means an attacker who later flips their well-known gets picked up on your next fetch, whereas a cached binding would be sticky.
On submodules
Half-fair, but it doesn't rescue the argument. Submodules (clone --recurse, submodule update) genuinely are a way to invoke git-remote-entire against a URL you didn't type — that part isn't nonsense. But:
- It's the identical explicit well-known fetch either way; nothing silent or magic, as you said.
- The exposure (such as it is) happens on first contact regardless of persistence, so "don't persist" is the wrong control for it. The right control, if you cared, is prompting before authenticating to a never-seen host — which the audience-pinning already makes largely moot.
What's actually true
- The honest reason this CLI doesn't write
cluster_contextsis a division of labor:entire-coreowns binding writes; this binary is a thin helper that resolves ephemerally. The threat model in resolve.go:23 is post-hoc justification that conflates "a host got contacted" with "a credential got leaked." They're not the same here, because of the core-only login-JWT exchange + audience pinning. - The kernel of legitimacy: TOFU-style caching does record "host X authenticates via core Y," and re-discovery avoids a stale mapping. But that's a correctness nuance, not the "silent identity-JWT channel" the comment sells.
Net: persisting on successful resolution (option B from before) is not the security hole the comment claims. If you want symmetry, B is defensible — or just add the explicit entire auth bind (A). Want me to rewrite that resolve.go comment to say what's actually true, and/or wire up one of the two?
Three places carry the threat model; two of them make the misleading "identity-bearing JWT / silent durable channel" claim. The ClusterContexts struct doc and the current_context-fallback paragraph are accurate, so I'll leave those. Editing resolve.go and auth_context.go.
Let's not talk about entire-core, that's a concern of a company-private repo. We will be bringing the behaviours in line, but the framing will be meaningless to outsiders.
Good call — dropping the entire-core references. Let me reframe in terms of what this helper does, without naming any private tooling.
I don't want comments saying "sometime in the past XYZ held true". focus on NOW, write in present tense, and only discuss the code's state today.
Done. Committed as ea605a648 (not pushed).
What changed — two files, comments only:
resolve.go: replaced the false "drive-by clone mints identity-bearing JWTs" rationale with the truth — login JWT only ever goes to the resolved context'sCoreURL, the cluster host only gets a repo-scoped/audience-pinned token, so not-persisting is a correctness choice (immediacy + no host-controlled mapping lingering), not a security one. Also rewrote the adjacentcurrent_context-fallback paragraph to present tense (dropped "the old fallback would…").auth_context.go: the audit listing no longer says bindings "mint identity-bearing JWTs"; it now says a binding pins which core authenticates a host.
Notes:
- Go lint + targeted tests for both packages pass.
mise run linthas one failure — a pre-existingshellcheckSC2312 inmise-tasks/release, which I confirmed fails with my changes stashed. Untouched and unrelated; left it alone rather than scope-creep. Flag if you want it fixed separately.- No entire-core references; nothing about past behavior.
I have the full picture. Here it is.
Timeouts relevant to git-remote-entire
Bottom line: only the small control-plane calls have a hard total timeout (30s). The actual git transfer has no total timeout by design — it's bounded only by a fast connect timeout plus Ctrl-C/parent-context cancellation. So large clones won't get guillotined, but a stalled mid-stream transfer relies on TCP/the user to break it.
| # | Value | Where | Applies to | Notes |
|---|---|---|---|---|
| 1 | 30s total request | main.go:87 (http.Client.Timeout) | /.well-known discovery (ResolveContextForCluster) + OAuth token exchange (repocreds) | This client is passed only to discovery + token exchange. Fine for small JSON; would be a footgun on a stream, but it's never used for one. |
| 2 | none (no Timeout) | proxy.go:107 (p.client) | the git data transfer — info/refs, git-upload-pack, git-receive-pack | Deliberate: a total timeout would kill big clone/fetch/push. Bounded instead by #3 (connect) + parent ctx (signals). No idle/stall timeout, though. |
| 3 | 500ms TCP connect (DefaultDialTimeout), env override ENTIRE_CONNECT_TIMEOUT_SECONDS | httpclient/transport.go:23,28 | every dial on both clients (shared NewTransport) | Intentionally short — it's the replica-failover knob, so a dead node fails over fast. Invalid env value → falls back to 500ms with a stderr warning. |
| 4 | 2s grace | main.go:186 (installSignals → time.Sleep) | shutdown after first SIGINT/SIGTERM | Not a network timeout. After 2s prints "shutdown taking longer…"; second Ctrl-C hard-exits. Cancelling ctx aborts in-flight transfers. |
| 5 | 1m SafetyMargin (capped at TTL/2) | repocreds.go:31,127 | scoped-token cache rotation | Freshness margin, not a network timeout — re-mints a repo-scoped token ~1m before expiry. |
No timeout set anywhere: TLS handshake, response-header, idle-conn, keep-alive — all Go defaults (i.e. unbounded except OS-level), on both p.client and noRedirectClient (proxy.go:342, which reuses the same transport, also no Timeout).
The one gap worth flagging: the data-plane client (#2) has no idle/stall timeout. A connection that establishes (passes the 500ms dial) then hangs mid-packfile won't time out on its own — it waits on TCP or the user hitting Ctrl-C. If you want resilience against a half-dead replica that accepts the connection but stops sending, that'd need a stall-detector (e.g. a ResponseHeaderTimeout on the transport, or a read-deadline wrapper), not a total Timeout. Want me to dig into whether that's a real risk given the replica-failover logic?
500ms TCP connect
that one sometimes bites us on initial connect. Let's push that one up to 1s.
Done. Committed as d026319e1 (not pushed).
DefaultDialTimeout500ms → 1s inhttpclient/transport.go, comment updated to explain the slow-initial-connect case.- No hardcoded
500in tests — they reference the symbol, so they still pass. fmt + go lint clean. ENTIRE_CONNECT_TIMEOUT_SECONDSoverride unchanged.
This affects both clients sharing NewTransport (control-plane and git data transfer dials), since the failover path is the same.
ok i made a spinoff branch. back to those contexts. there are a few problems in our implementation:
-
it diverges from original git-remote-entire (see ~/src/entirehq/entiredb/cmd/git-remote-entire) in that we should cache in contexts.json all successful cluster->core binding lookups. they don't change all the time, they're essentially fixed, we just don't want to ship hardcoded values in code. please compare this implementation with the original and summarise discrepancies.
-
we have 2 views of "what's my session" - see:
11:47:04 (git)! main▲ $ entire auth list ID NAME SCOPE CREATED LAST USED EXPIRES 019e0189-038e-726a-9d48-40ae68fe573c CLI login 2026-05-07 cli 2026-05-07 4d ago 2026-06-06 019e7148-93cb-7d66-8504-ac81ae4f5318 CLI login 2026-05-29 cli 2026-05-29 never 2026-06-28
11:45:58 (git)! main▲ $ entire auth contexts us.auth.entire.io toothbrush https://us.auth.entire.io
- eu.auth.partial.to toothbrush https://eu.auth.partial.to
Cluster bindings (these hosts auto-authenticate with a stored context): aws-eu-central-1.entire.io -> us.auth.entire.io
we'll think about consolidating that later.
I now have the complete original mechanism. Here's the comparison.
Bottom line
Ours never persists cluster→context bindings; the original persists them in two places. Every git pull in our implementation pays a full /.well-known discovery round-trip that the original pays only once per cluster. That's the core divergence, and it's a deliberate removal on our side (the comment I just wrote even brags about it).
Where the original persists (two mechanisms, belt-and-suspenders)
1. Inline in clusterdiscovery/resolve.go — speculative, on discovery match (before any auth):
Its doc point 2: "the first advertised URL with a local context wins, and the cluster→context binding is written so the next call skips discovery."
2. Post-success hook in auth/scoped.go + git-remote-entire/main.go — only after the first successful STS exchange:
Note these two contradict each other: resolve.go already binds speculatively on discovery match, so by the time the "never speculative" hook fires, the binding is usually already written (BindCluster is idempotent). The hook is effectively redundant. If we re-introduce persistence we get to pick the cleaner of the two rather than copying both.
Discrepancy table
| # | Concern | Original (entiredb) | Ours | Impact |
|---|---|---|---|---|
| 1 | Persist binding on discovery match | resolve.go calls BindCluster inline | removed — replaced with "ephemeral; binding not persisted" debug log | Every pull re-runs /.well-known; bindings never accumulate (your symptom) |
| 2 | Post-success auto-bind | ScopedAuth.onFirstSuccess → BindCluster | no ScopedAuth wrapper; repocreds.New called directly, no success hook | no binding written even after proven auth |
| 3 | makeScopedSuccessHook in main | present, wired into scoped auth | absent | — |
| 4 | resolve.go doc/intent | "binding written so next call skips discovery" | "this helper does not persist … correctness choice" | our comment actively asserts the opposite behavior |
| 5 | ENTIRE_TOKEN env override (CI/workload identity) | resolveCredentials handles it via CoreURLFromEnvToken (reads aud) | absent — we go straight to MigrateLegacyLoginContext + resolve | CI/automation token flow unsupported |
| 6 | Token refresh | client.GetTokenWithRefresh (refresh token from keyring) | auth.LoginTokenForContext — read-only, no refresh; 401 → re-login | expired token = hard re-login, no silent refresh |
Discrepancies 1–4 are the same root issue (no persistence) and are what you asked about. 5–6 are separate divergences I noticed while tracing — flagging them but they're out of scope for "cache the bindings."
The choice point for fixing #1–4
repocreds.New in our repo takes no success callback, so re-introducing persistence means one of:
- A — speculative, inline in resolve.go (copy original mechanism #1): one-line
BindClusterafter the discovery match. Simplest, matches original's actual behavior. Binds before auth is proven. - B — post-success only (original mechanism #2, the stated intent): add an
onFirstSuccesshook torepocreds.Cache.Token(or wrap it), fireBindClusterafter the first real exchange. Only persists once the cluster has actually minted a token for you — defensible, and consistent with the security reasoning we worked through earlier.
Given we already established persistence is safe (login JWT never reaches the cluster host; cluster only gets an audience-pinned token), either is fine. I'd recommend B — it's the cleaner of the original's two mechanisms and avoids writing a binding for a /.well-known that matched but then failed to authenticate.
Either way I'll need to rewrite the resolve.go comment I just committed (it currently asserts non-persistence as a feature). Want me to implement B (and drop the now-wrong comment), or do you prefer A to match the original's resolve.go line-for-line?
Done. Two commits on 20260602-auth-contexts (not pushed):
823f0349c— behavior + comment reconciliation4f44cc845— tests
What changed (option B, conservative):
git-remote-entire/main.go: newmakeBindHook— async.Once-guarded closure that callscontexts.BindClusterand fires fromsetAuthafter the first successful scoped-token exchange. So a cluster→context binding is persisted only once the cluster has provably authenticated the context.clusterdiscovery/resolve.go: behavior unchanged — resolution still doesn't persist. Only the comments/debug string updated to say the caller binds post-success (and the now-correct note thatauth useaffects only not-yet-bound clusters).
Vs. the original: we adopted only the post-success mechanism (entiredb's scoped.go hook), not its speculative inline BindCluster in resolve.go. That's the safer of its two — the original binds on bare discovery match before auth is proven, which can leave a stale binding for a host that matches but can't authenticate.
Verification: fmt + go-lint clean; clusterdiscovery, contexts, and git-remote-entire package tests pass. The existing resolve_test.go invariant "discovery must not persist a cluster binding" still holds, because persistence moved out of resolve into the caller.
Still open (per your note, deferred): the two-views problem (auth list vs auth contexts) — not touched. And the entiredb divergences I flagged but didn't act on: no ENTIRE_TOKEN env override, no token refresh. Say the word if you want either next.
what if i'm logged in as paul@core-us as well as admin@core-us (both bound to cluster1), but have my active context set to paul@unrelated. if i then try ops against cluster1, how is the correct context picked?
Traced it through the actual code. Here's the precise answer.
Bottom line
In your scenario the active context (paul@unrelated) is ignored, and the pick is not by activeness — it's first-match-in-on-disk-order, then frozen by the new bind-on-success. With two accounts on the same core and an unrelated active context, there's no signal to disambiguate, so you silently get whichever of paul@core-us / admin@core-us logged in first — and that choice sticks.
First, a correction on framing
A cluster can only be bound to one context — cluster_contexts is host → name (1:1). So "both bound to cluster1" can't literally hold. What's true is both are eligible for cluster1 (both issued by core-us, which cluster1's /.well-known advertises). The binding map holds at most one.
The selection, step by step (resolve.go)
Case A — cluster1 already bound (the common case after the first op):
cluster_contexts["cluster1"]names one context → returned as-is, discovery skipped, active context irrelevant. Whichever got bound first wins, permanently, until youauth unbindor delete that context.
Case B — cluster1 not yet bound (first-ever op, or after unbind):
2. Discovery fetches cluster1's /.well-known → advertised core_urls include core-us.
f.ContextsForIssuer("…core-us")returns bothpaul@core-usandadmin@core-us, in on-disk order (confirmed:contexts.go:118iteratesf.Contextsin order; new logins append, so order = login order).c := matches[0]— first listed.- Prefer-current tie-break (
resolve.go:75): only overrides if the active context is among the matches.paul@unrelatedis on a different core → not inmatches→ tie-break does nothing. - Returns
matches[0]= first-logged-in account on core-us.
- On the first successful scoped-token exchange,
makeBindHookpersistscluster1 → matches[0]. Now Case A applies forever.
The footgun
If admin@core-us happens to be matches[0] (e.g. you logged in as admin first), an op against cluster1 from your paul@unrelated shell silently authenticates as admin, with no prompt and no error — and the new bind-on-success freezes that. git-remote-entire can't prompt to disambiguate: stdout is the git pkt-line stream, so there's no interactive channel.
The prefer-current logic only saves you when your active context is itself eligible for the cluster. When it isn't (your exact scenario), it's pure on-disk order.
Note this is no worse than the original — entiredb's resolve.go also takes matches[0] and has no prefer-current tie-break at all. Ours added that, so we're strictly better when the active context is eligible; identical when it isn't.
Recourse / mitigation
- Inspect:
entire auth contextsshows the binding (cluster1 -> admin@core-us). - Fix:
entire auth unbind cluster1, thenentire auth use paul@core-us, then re-run — but careful: after unbind, the next op re-runs discovery, and sincepaul@core-usstill isn't your active context unless you switched, you'd again getmatches[0]. The reliable fix is an explicit bind. We removed inline persistence but there's noentire auth bindcommand (onlyunbind) — so today you can't deterministically pincluster1 → paul@core-uswithout editingcontexts.jsonby hand.
That last point is the gap worth closing if this scenario is real for you: a symmetric entire auth bind <host> <context> (the BindCluster plumbing already exists, unused) would let you pin the right account deterministically instead of relying on login order. Want me to add it?
OK, yep, that uncovers a bit of nastiness. I want us to adjust things as follows (let's make a plan and discuss first, don't jump to impl):
- the idea of binding/unbinding isn't great. what i'd rather see is that we cache the mapping from cluster -> core - they can have a TTL of 7d and fetch on miss, because we're not adding/removing data clusters all the time. once a data plane is managed by a core control plane, it'll stay. so instead of bind/unbind we store e.g., royalcanin.partial.to->["us.auth.partial.to", "eu.auth.partial.to"] and aws-us-east-2.entire.io->["us.auth.entire.io", "eu.auth.entire.io"]. If we then try an operation against aws-eu-central-X.entire.io, we don't know that cluster, so we fetch its /.well-known, and store the result.
we will also rework the way we pick which context to use.
- as before, if the current context has acceptable auth for the operation we're about to perform, use it.
- however, if we need to pick another context, we'll first find out the list of contexts the user has, that can perform the given action. ONLY if the list is len == 1, we'll default to using that instead. That'll cover 99% of users. however, for admins and developers that have, say alice@core and bob@core, but selected alice@unrelated - we should FAIL with an error saying "both alice@core and bob@core matched this operation, please 'entire context use XX' explicitly".
All four locked in. Here's the finalized plan with your decisions baked in.
Finalized design
Cache file — ~/.cache/entire/cluster_cores.json (reusing the same cache-dir resolution as nodes.json/replicas), separate from contexts.json:
- 7d TTL (
const ClusterCoreTTL). Read: fresh → use; stale/missing →Discover(), rewrite. - Stale fallback: expired + refetch fails → use stale
core_urls+ warn. Only a hard fail when expired/missing and no stale entry to fall back on. - Negative results never cached.
- Own flock'd read-modify-write (mirroring
contexts.Modify); concurrent submodule fetches racing to fill the same entry is fine (idempotent).
Selection (SelectContext, flat union):
- Active context eligible (
current.CoreURL ∈ core_urls) → use it. - Else
eligible = {ctx : ctx.CoreURL ∈ core_urls}:1→ use it;0→ login-hint error;>1→ hard fail listing names:"alice@core and bob@core both match this cluster; choose one with 'entire auth use <name>'".
Seamless upgrade (the priority):
- New cli stops reading
cluster_contexts. Go ignores the now-unknown field, so an existingcontexts.json(incl. your realaws-eu-central-1 → us.auth.entire.io) loads without error. - On first op against any cluster, we fetch
/.well-knownand recompute. For the 99% single-eligible-context user this is invisible. The only behavior change is multi-account users now get an explicit error instead of a silent arbitrary pick — which is the fix. - I'll leave
cluster_contextsinert incontexts.jsonrather than rewrite that file to strip it (less risk to the credential file; it's harmless dead JSON). Flag if you'd rather actively strip it.
Removals: BindCluster, File.Resolve, ClusterContexts reads, makeBindHook/onScopedSuccess, entire auth unbind, UnbindCluster, ClusterBindings, printClusterBindings. RemoveAllContexts clears the new cache file instead.
Commit sequence (early & often)
- New
clustercorescache package (file I/O, TTL, stale-fallback) + unit tests — self-contained, no wiring. SelectContext+ rewriteResolveContextForClusterto cores-cache + selection; ambiguity error + tests. (resolve behavior changes here.)- Strip persistence from
git-remote-entire(makeBindHook/hook removal). - Remove bind/unbind command surface; adjust
auth contexts; clear cache inRemoveAllContexts. - Docs (CLAUDE.md strategy/auth notes if any reference bindings).
Two small residuals before I start
- Flush command? With stale-fallback + 7d TTL, a cluster that genuinely re-homes cores could be stuck up to 7d. A minimal
entire auth refresh [host](drops cache entries) is a cheap escape hatch. Include in v1, or skip? auth contextsoutput: just drop the "Cluster bindings" section, or replace it with a read-only view of the cluster→cores cache (with age)? I lean drop-for-now, add the view later if wanted.
Give me the call on those two and a green light, and I'll start with commit 1.
no flush command, user can rm ~/../cluster_cores.json
auth contexts: drop mention of bindings.
The redesign is complete on 20260602-auth-contexts (not pushed).
Bottom line
Cluster→context bindings are gone. We now cache only the objective cluster → [core_urls] fact (TTL'd) and recompute which account to use on every operation, failing loudly on genuine ambiguity instead of silently freezing an arbitrary pick.
What landed (4 new commits)
discovery: TTL'dcluster_cores.json—host → {core_urls, fetched_at}, 7d TTL, stale-fallback on fetch failure, in the cache dir besidenodes.json. Shared lock/atomic-IO factored out (no dup).clusterdiscovery: rewrite resolution — cores from cache (live fetch on miss/expiry) + per-op selection: active context wins if eligible → else sole eligible → else error listing candidates +entire auth use. DroppedmakeBindHook.auth: remove the binding surface —BindCluster,File.Resolve,ClusterBindings,UnbindCluster,entire auth unbind, and the "Cluster bindings" section ofentire auth contexts.- Removed an accidentally-committed build artifact + gitignored it.
Your scenario now
paul@core-us + admin@core-us, active paul@unrelated, op against cluster1 → hard error: multiple login contexts can authenticate against cluster cluster1 (admin@core-us, paul@core-us); choose one with 'entire auth use <context>'. No silent wrong-account, no frozen choice.
Seamless upgrade
ClusterContextsfield kept but inert (deprecated comment) — oldcontexts.jsonround-trips, no data loss, no clobbering during the entiredb transition.Deletestill prunes it; full logout still clears it.- Single-account users (the 99%) see no behavior change; the cores fetch is cached after first use.
Verification
go build ./...,go vet, all repo unit tests pass,mise run fmt+lint:goclean (0 issues).- Pre-existing
shellcheckSC2312 inmise-tasks/releasestill fails fullmise run lint— unrelated, untouched.
Not done (deferred / yours)
- Two-views consolidation (
auth listvsauth contexts) — you said later. - entiredb backport of the same model — yours.
- I see you bumped the dial timeout to 2s (commit
328390b6b); left as-is.
ClusterContexts field kept but inert
can we drop that too? or will an old config fail to parse?
Both done. Bottom line:
- Dropped
ClusterContextsentirely — safe becausecontexts.gouses plainjson.Unmarshal(noDisallowUnknownFields), so an oldcontexts.jsonwithcluster_contextsstill parses; the key is ignored and falls off on the next write. Removed the field, the dead "used to live here" comment, and the last references inDelete/RemoveAllContexts. Deletenow always clearscurrent_contextwhen the active context is deleted — no fallback toContexts[0]. Updated the test (TestDelete_OfCurrentClearsCurrent) andRemoveCurrentContext(its redundant reassignment + stale "advances" comment gone).
Verified: go build ./..., full lint (0 issues), and contexts/auth/cli package tests all pass. Committed as 478c2bb26 (not pushed).
i pushed the branch. let's draft a PR description before opening a PR. the reviewers tend to be very worried about making the UX worse, so let's focus on 1) what issue(s) existed, 2) how the 99% happy path will be totally unaffected. we have decreased wrong auto-assumptions in our code and will surface doubts to the user, instead of guessing.
Here's a draft. It leads with the UX-safety framing the reviewers care about.
Rework cluster→context resolution: cache infra facts, stop guessing accounts
The problem
Authenticating an entire:// git op requires picking which local login context to use for the target cluster. The old code did two things that could bite multi-account users and confuse everyone else:
- It could silently authenticate as the wrong account. When more than one local context was eligible for a cluster (e.g.
alice@coreandadmin@core) and your active context was something unrelated, resolution fell back to "first match in on-disk/login order." So an op could quietly run asadmin@corewith no prompt and no error — and then freeze that arbitrary choice by persisting it as a cluster→context binding. - It conflated two unrelated facts. A binding mixed which control plane fronts a cluster (objective, near-static infra) with which of my accounts to use (a per-user choice). That made the persisted state asymmetric and confusing — e.g. one cluster recorded, another not, even after using both — and it's what froze the wrong-account pick above.
- Deleting your active context silently switched identity.
Deleteadvancedcurrent_contextto whatever remained, so removing a login could quietly re-authenticate you as someone else.
What changed
- Cache only the objective fact:
cluster → [core_urls], in a TTL'dcluster_cores.json(memoizes the/.well-knowndiscovery, 24h TTL, falls back to a stale entry if a re-fetch fails). The account is recomputed every operation, never persisted. - Selection now refuses to guess. Active context if it's eligible → else the sole eligible context → else, if 2+ are eligible and none is active, a clear error listing the candidates and telling you to
entire auth use <context>. No silent arbitrary pick. - Deleting the active context clears
current_contextrather than switching you to another identity. - Removed the now-meaningless bind/unbind surface (
entire auth unbindand the "Cluster bindings" section ofentire auth contexts).
Net: fewer wrong auto-assumptions; where the old code guessed, we now either do the one unambiguous thing or surface the doubt to the user.
The 99% happy path is unchanged
- Single account (the common case): exactly one context is eligible for the cluster → used automatically. No prompt, no error, no extra step — identical to today.
- No added latency: the
/.well-knownlookup is cached (24h), so repeat ops don't re-fetch. First op against a cluster does one discovery fetch, same as before. - No migration, no re-login: an existing
contexts.jsonloads unchanged. The obsoletecluster_contextskey is simply ignored (and drops out on the next write). Logins and the active context are untouched. - The new error only fires for genuine ambiguity — 2+ accounts on the same core with an unrelated active context. That's admins/devs juggling identities, who previously risked acting as the wrong one and now get a one-line "pick one" instead.
Also in this branch (separate, small)
- Default dial timeout 500ms → 2s — a cold initial connect (DNS/TLS-fronting LB/distant region) was sometimes tripping the old budget; still fast to fail over off a dead node. Override via
ENTIRE_CONNECT_TIMEOUT_SECONDS. - Corrected stale comments in the resolver to match the actual token flow.
Want me to open it with gh pr create (and if so, against main)? I can also split the dial-timeout/comment commits into a separate PR if you'd rather keep this one purely about the context rework — though they're already pushed together.
try again but waaaaaaay more succinct. drop problem 3, and tone down the alarm in problem 1
Cache cluster→core mappings; pick the login context fresh per op
Problem
Resolving which login context to use for an entire:// cluster mixed two concerns: which control plane fronts a cluster (stable infra) and which of your accounts to use (per-user). When several contexts were eligible and none was active, it picked one by save order and persisted that guess as a binding — so the choice was somewhat arbitrary, and the stored state ended up asymmetric and confusing.
Change
- Cache only the objective
cluster → [core_urls]fact (cluster_cores.json, 24h TTL, stale-fallback). The account is recomputed every op, never persisted. - Selection: active context if eligible → else the sole eligible one → else (2+ eligible, none active) a one-line error listing them and pointing at
entire auth use, instead of guessing. - Drops the now-defunct bind/unbind surface.
99% happy path: unchanged
- One account → exactly one eligible context → used automatically. No prompt, no extra step.
- No added latency —
/.well-knownis cached; first op per cluster fetches once, as before. - No migration / re-login — existing
contexts.jsonloads as-is; the obsoletecluster_contextskey is ignored. - The new error fires only on genuine ambiguity (2+ accounts on the same core, none active) — previously a silent guess, now a clear "pick one."
Also in this branch
Dial timeout 500ms→2s (cold-connect headroom; override via ENTIRE_CONNECT_TIMEOUT_SECONDS) and a resolver comment fix.
Open it with gh pr create against main?
fix this guy Running [/home/runner/golangci-lint-2.11.3-linux-amd64/golangci-lint run] in [/home/runner/work/cli/cli] ... Error: internal/entireclient/discovery/cache.go:133:1: readCacheFile returns generic interface (T) of type param any (ireturn) func readCacheFile[T any](path string, newEmpty func() T) (T, error) { ^ 1 issues:
- ireturn: 1
Error: issues found Ran golangci-lint in 62002ms
Fixed and committed (0feecb4b0). Push it and CI should go green.
Bottom line:
- Root cause: the generic
readCacheFile[T]returned its type paramT, whichireturnflags. The//nolint:ireturnI'd added kept getting auto-stripped by the localgolangci-lint run --fix(nolintlint), so it only failed in CI's no-fix run. - Fix: replaced it with
loadCacheFile(path, *T, newEmpty) error— fills a pointer, returnserror(whichireturnpermits). Same shared read/unmarshal/corrupt-reset logic, no suppression directive to lose. - Verified:
go build ./..., discovery tests pass, and a fullgolangci-lint run(no--fix, like CI) reports 0 issues — so noireturnand noduplregression from the two now-tiny readers.
Pushed — 7b3a2bbf0..e67d7e189 on 20260602-auth-contexts.
- Rebased my
ireturnfix on top of the remote's Copilot comment-reword commit (no conflicts), then pushed. - The
entire/checkpoints/v1sync warning is just the checkpoint remote being unreachable — your branch push itself succeeded (objects stored, replicas updated).
CI should now run with the ireturn fix in place.
o hey remind me right at the beginning we also flagged other gaps with this entireio/cli auth mechanism.
Yep — two divergences from the original entiredb git-remote-entire that I flagged while tracing, then explicitly left out of scope for the cluster-context rework:
-
No
ENTIRE_TOKENenv override. The originalresolveCredentialshonorsENTIRE_TOKEN(CI / workload-identity), deriving the core URL from the JWT'saudclaim viaCoreURLFromEnvToken— skippingcontexts.json/keyring entirely. Ours goes straight to context resolution, so the CI/automation bearer-token flow isn't supported. -
No token refresh. The original mints via
client.GetTokenWithRefresh(refresh token from the keyring). Ours usesauth.LoginTokenForContext— a read-only fetch, no refresh; an expired token just surfaces as a 401 → hard re-login, no silent refresh.
Both are independent of the binding/selection work we just shipped. Want me to scope either of them next? #1 (ENTIRE_TOKEN) is the smaller, self-contained one and the more likely to bite CI users.
Handover: two remaining git-remote-entire auth gaps
Both are independent of the cluster-context rework just merged. The original entiredb git-remote-entire is the reference implementation for both — clone-compatible spec lives in ~/src/entirehq/entiredb.
Gap 1 — ENTIRE_TOKEN env override (CI / workload identity)
What: Let ENTIRE_TOKEN=<jwt> bypass contexts.json/keyring entirely, so CI and workload-identity runners authenticate without an interactive login.
Reference (entiredb):
cmd/git-remote-entire/main.go→resolveCredentials(): checksENTIRE_TOKENfirst; if set, derives the core URL from the token and returns it as a static login JWT, skipping context resolution.internal/remotehelper/entire/auth/context.go→CoreURLFromEnvToken()+jwtAudiences(): reads the URL-shapedaudclaim (notiss). Login/SA-session JWTs carryaud=<home-region URL>, which is what STS routing keys on.audcan be a string or array (RFC 7519) — handle both.
Where in our repo (cli):
cmd/git-remote-entire/main.go→run(): add theENTIRE_TOKENbranch beforeMigrateLegacyLoginContext/ResolveContextForCluster. When set, skip both and buildrepocreds.New(coreURL, clusterBaseURL, staticProvider, httpClient)wherestaticProviderjust returns the env token andcoreURLcomes from itsaud.- We already parse JWTs via
github.com/entireio/auth-go/tokens(tokens.ParseClaims, used incmd/entire/cli/auth/contexts.go). Check whether it exposes the audience; if not, port entiredb's smalljwtAudienceshelper.
Acceptance:
ENTIRE_TOKEN=<jwt> git clone entire://host/...works with nocontexts.jsonand no keyring entry.- A token with no URL-shaped
aud→ clear error ("must be a login or sa-session JWT whose aud is the home-region URL…"), not a silent fallback. - Unit test on the
aud-extraction (string form, array form, missing/opaque → error).
Size: small, self-contained. Do this one first — most likely to bite CI users.
Gap 2 — silent token refresh
What: When the stored access token is expired, refresh it via a stored refresh token instead of failing the op and forcing a re-login.
Reference (entiredb):
cmd/git-remote-entire/main.go→resolveCredentials()usesclient.GetTokenWithRefresh(ctx, AuthRefreshConfig{KeyringService, BaseURL, Username, CoreBaseURL, HTTPClient, ClientID}).
Where in our repo (cli):
cmd/entire/cli/auth/contexts.go→LoginTokenForContext()is read-only: it fetches the stored token, strips the encoded expiry, and returns it. Its own comment notes "the device-flow login holds no refresh token, so an expired token surfaces as a 401."- The
LoginJWTProviderpassed torepocreds.Newingit-remote-entire/main.gois where a refreshing provider would slot in —repocredsalready calls the provider per exchange, so refresh-on-demand fits without restructuring.
⚠️ Dependency / why this is bigger than Gap 1: our device-flow login currently doesn't obtain or persist a refresh token. So this is two pieces:
- Login side —
login.go/ the device-flow exchange must request + store a refresh token (andclient_id) in the keyring alongside the access token. - Use side — replace the static
LoginTokenForContextprovider with one that refreshes when the access token is within/past expiry, persisting the rotated token.
Acceptance:
- An expired access token with a valid refresh token auto-refreshes mid-operation; no re-login prompt.
- Re-login hint only when the refresh token is also absent/expired.
- Rotated tokens are persisted back to the keyring.
Size: medium; touches login + the provider. Scope the login-side refresh-token persistence first — without it, the use-side has nothing to refresh from.
Both reference the entiredb originals verbatim, so whoever picks these up can diff our git-remote-entire/main.go against ~/src/entirehq/entiredb/cmd/git-remote-entire/main.go for the exact shape.
Yes — it's standard and reasonable.
Bottom line: The OAuth 2.0 Device Authorization Grant (RFC 8628) issues tokens via a normal token-endpoint response, which can include a refresh_token just like any other grant. Long-lived CLI/device sessions are exactly the use case device flow exists for — re-running the browser dance on every token expiry is the thing you're trying to avoid.
It's the norm, not an exception:
gh, AWS SSO/OIDC, Azure CLI, gcloud, kubectl OIDC plugins — all use device (or similar) flow and persist refresh tokens so the CLI stays logged in for days/weeks.- Your own sibling already does it: entiredb's
git-remote-entirecallsclient.GetTokenWithRefresh(...). That means the auth server (entire-core / its OIDC provider) is already configured to issue refresh tokens for this same flow. So this isn't "is it possible" — it's "our CLI just isn't requesting/storing what the backend already hands out."
The real preconditions (worth confirming, not blockers):
- Server issues them — typically gated on the
offline_accessscope in the device-auth request. entiredb's path implies it's already enabled. - Public client — the CLI has no client secret. Refresh tokens for public clients are standard (PKCE/device flow), but best practice is refresh-token rotation with reuse detection: each refresh returns a new refresh token and invalidates the old one, so a leaked-and-replayed token is caught. Check whether entire-core rotates.
- At-rest storage — they live in the OS keyring, same place the access token already sits. No new storage surface.
So for Gap 2, the design is sound; the work is (a) add offline_access to the device-auth request and persist the returned refresh token, (b) use it. The only thing to verify with whoever owns entire-core is whether rotation is on — if it is, the use-side must persist the rotated token every refresh (already in the Gap 2 acceptance criteria).
One nuance to flag in the handover: if entire-core rotates refresh tokens, concurrent git operations (e.g. git fetch --recurse-submodules spawning several git-remote-entire processes) can race on the refresh — two processes refresh with the same token, one rotation wins, the other's refresh token is now invalid. The repocreds/keyring layer would need single-flight or a retry on refresh-token-rejected. Worth a line so it isn't discovered the hard way.
Want me to fold that refresh-token-rotation + concurrency note into the Gap 2 handover above?
sounds like you're across it. please implement the necessary bits for "gap 2" on a new, fresh branch of entireio/cli and entirehq/entiredb
Done. Gap 2 is implemented on a fresh branch 20260602-token-refresh (committed c508f33e2, not pushed).
Key finding that reshaped the task
No entiredb/server work was needed — so I made no branch there. entire-core already issues refresh tokens on the device grant (gated on offline_access), enables the refresh_token grant for the entire-cli public client, and runs rotating single-use refresh families with reuse detection + family revocation. All the work was client-side in entireio/cli, which was throwing the refresh token away.
What I built
- Login side: request
offline_access; carry the refresh token through the device-poll plumbing (DeviceAuthPoll→waitForApproval→runLogin); persist it to the<service>:refreshkeyring slot inRecordLoginContext. - Refresh-aware provider:
NewRefreshingLoginProviderwires atokenmanager.Manager(already a dependency) via a ~90-line per-contexttokenstore.Storeadapter;git-remote-entireuses it instead of the read-only token fetch.
On your "what would it take for tokenmanager" question
It turned out to be the smaller, safer option: Manager.Refresh already bundles the cross-process file lock + in-process single-flight + rotation persistence + re-auth detection — exactly the concurrency hazard (racing submodule fetches replaying a single-use token → family revoked) I'd otherwise have hand-rolled. The only genuinely new code is the adapter.
UX safety (same framing your reviewers care about)
Strict superset of today: a still-valid token returns with no network call; a pre-change login with no refresh token behaves exactly as before (valid→used, expired→re-login). Only difference: new logins now refresh silently instead of forcing re-auth.
Verified
fmt + lint:go clean (0 issues); new tests cover the store round-trip, validation, the no-network valid path, expired-no-refresh, and the full refresh+rotation against a stub token endpoint; entire repo unit suite passes.
Want me to push this branch and draft its PR description too?
Read entiredb's full refresh path (client/auth_interceptor.go). Here's the head-to-head.
Bottom line
Yes, we diverge — and ours is safer on the thing that matters most. entiredb hand-rolls refresh in its client package with no locking; ours uses auth-go's tokenmanager, which serialises refreshes across processes and goroutines. Given the server does single-use rotation + reuse-detection + family revocation, entiredb's version has a latent footgun that ours doesn't.
Where ours is better
entiredb (GetTokenWithRefresh) | ours (tokenmanager) | |
|---|---|---|
| Concurrent refresh | None. RefreshAccessToken reads→POSTs→writes with no lock. Two parallel git-remote-entire procs (recursive submodule fetch) both POST the same single-use refresh token → one wins, the rest get invalid_grant → server revokes the whole family → forced re-login. | Cross-process file lock + in-process single-flight + re-read after locking, so a late waiter reuses the peer's freshly minted token. No reuse, no revocation. |
| Rotation race | On invalid_grant it gives up (ErrSessionExpired). | Re-reads the store; if the RT was rotated under it, retries once with the new token before declaring the family dead. |
| Code ownership | Bespoke refresh reimplemented in client/ (its own refreshViaCore, expiry math, etc.). | Uses the shared, tested auth-go library. Less bespoke auth code to maintain. Ironically entiredb is the one diverging from auth-go here. |
The concurrency gap is the real finding: entiredb's git-remote-entire calls GetTokenWithRefresh once at startup (so it's safe within a process), but cross-process it's exposed — exactly the submodule-fetch scenario. Worth flagging for a backport to entiredb (swap its hand-rolled path for tokenmanager, or add a lock).
Where entiredb is nicer (small, cheap to adopt)
- Transient-failure leniency. On a network/5xx blip mid-refresh, entiredb returns the existing token and lets the data call try (warns to stderr); ours returns the refresh error. For an expiring-soon-but-still-valid token this avoids a spurious failure. (For an already-expired token it's moot — the stale token 401s anyway.)
entr_prefix guard. entiredb checks the refresh token looks core-issued before POSTing, short-circuiting stale non-core tokens locally instead of via a serverinvalid_grant. Minor — ours just gets the server rejection.- Better re-login hint. entiredb says
entire-core auth login --base-url <core>(copy-pasteable, core-specific); ours saysrun entire login. We could cheaply name the core in our error.
Net
Ours is safer (concurrency/rotation) and more maintainable (shared lib); entiredb has the latent reuse-detection footgun. The two divergences worth acting on: (a) consider backporting tokenmanager to entiredb to close its concurrency gap, and (b) optionally adopt entiredb's nicer re-login hint in ours. Want me to add the core name to our re-login error (cheap), and/or note the entiredb backport in a handover?
Done — committed 515c7c5e28 on 20260602-token-refresh.
The refresh re-login errors now name the core:
login session for "alice@core" (https://us.auth.entire.io) expired; run \ENTIRE_AUTH_BASE_URL=https://us.auth.entire.io entire login` to re-authenticate`- same for the no-refresh-token case.
Uses the CLI's own idiom (ENTIRE_AUTH_BASE_URL=<core> entire login, matching RenderLoginHint) rather than entiredb's --base-url flag, since that's how this CLI targets a specific core. Test now asserts the hint names the core and the login command. fmt + lint clean, auth tests pass.
Not pushed. Want me to push 20260602-token-refresh and draft its PR description?
ok so.. what have we done here. auto-refresh of login JWT right? No difference from the original ~/src/entirehq/entiredb/cmd/... right?
Short answer: same goal, not the same code — and ours is deliberately not a carbon copy.
-
What we did: yes — auto-refresh of the login JWT for
git-remote-entire. Requestoffline_accessat device login, persist the refresh token, and refresh-on-expiry instead of failing with a re-login. -
Where it's the same as entiredb: the end-user outcome and the wire protocol are identical —
grant_type=refresh_tokento<core>/oauth/token, rotated token persisted,invalid_grant→ re-login. In the common single-process case you can't tell them apart. -
Where it's not "no difference":
- Concurrency. entiredb's
GetTokenWithRefreshhas no lock. Ours goes through auth-gotokenmanager(cross-process file lock + single-flight + rotation-race retry). Under parallelgit-remote-entireprocesses (recursive submodule fetch), entiredb's can replay a single-use refresh token and get the family revoked; ours can't. This is a real, intentional improvement — not a stylistic one. - The login side is genuinely new here. entiredb's
git-remote-entireconsumes refresh tokens that its operator login (entire-core auth login, loopback PKCE) persisted. Theentire-clidevice-flow login — which lives in our repo — never requestedoffline_accessor stored a refresh token at all. So we didn't mirror entiredb there; we filled a gap entiredb's own flow didn't have to.
- Concurrency. entiredb's
So: functionally equivalent refresh behavior, reached via the shared library instead of a hand-roll, plus the device-flow login plumbing that only our repo needed. If you want true parity you'd actually go the other way — backport tokenmanager into entiredb to kill its unlocked-refresh footgun (the handover item I flagged).
Pushed 20260602-token-refresh → origin. Here's the PR draft:
Silent login-token refresh for entire:// git operations
What this does
git fetch/push/clone against entire:// clusters no longer break when your login token expires — the helper silently re-mints it from a stored refresh token instead of erroring out with "please log in again."
Why
Today the login JWT is fetched read-only: once it expires, every git op fails until you re-run entire login. We now request offline_access at login, persist the refresh token, and refresh on demand in git-remote-entire.
Happy path is unchanged
- Still-valid token: returned with no extra network call — zero latency change.
- Existing logins (pre-this-PR): behave exactly as before — valid token used, expired token surfaces a re-login prompt. Refresh only kicks in for logins created after this change (which now carry a refresh token).
- No migration, no forced re-login on upgrade.
Why it's safe under concurrency
entire-core issues single-use refresh tokens with reuse-detection (replay → whole family revoked). A naive refresh would let two parallel helper processes (e.g. git fetch --recurse-submodules) replay the same token and lock you out. We avoid that by going through auth-go's tokenmanager: cross-process file lock + in-process single-flight + re-read-after-lock + rotated-token persistence. A thin per-context tokenstore.Store adapter maps our keyring slots onto it.
Server
No server changes — entire-core already supports offline_access on the device grant, the refresh_token grant for the entire-cli client, and rotating families.
Tests
Store round-trip, validation, the no-network valid-token path, expired-without-refresh (re-login error names the core), and the full refresh+rotation against a stub token endpoint. fmt + lint clean; full unit suite passes.
Follow-up (not in this PR)
entiredb's git-remote-entire hand-rolls refresh without locking — same reuse-detection footgun. Worth backporting tokenmanager there.
Want me to open it with gh pr create against main?
do we refresh when we see that the JWTs exp is <1 min away, or do we only refresh on 400 error/
Proactive, off the JWT exp — and the window is 30 seconds, not 1 minute. No 4xx-triggered refresh of the login JWT.
The check (tokenmanager.coreTokenExpired):
So we re-mint when now + 30s >= exp (within 30s of expiry, or already past). Also refreshes if the token's nbf is in the future (clock-skew guard). Opaque non-JWT tokens have no visible expiry → treated as live → never proactively refreshed.
Reactive path: none for the login JWT. The only reactive bit is one layer down — repocreds invalidates its repo-scoped token cache on a data-plane 401 and re-exchanges; that re-consults the login-JWT provider, but the provider still only refreshes on the same 30s exp test, not on the error itself.
Why 30s is enough here (vs entiredb's 5-min buffer): the login JWT is only consumed in quick bursts to mint repo-scoped tokens at the start of an op — it isn't held across a long transfer. Once minted, the repo-scoped token (its own TTL) carries the packfile, and repocreds re-exchanges mid-transfer if needed. So a tight margin doesn't risk a clone dying halfway.
Worth noting the divergence: entiredb refreshes 5 minutes early (IsTokenExpiredOrExpiring); we refresh at 30s. Our SaveTokens still writes the |expiry stamp that the CLI's other readers (LoginTokenForContext, which uses the 5-min check) rely on — but the git-remote-entire refresh path itself is the 30s JWT-exp test. If you'd rather match entiredb's 5-min proactive window, that's a config knob we'd have to add (tokenmanager hardcodes exchangeSkew), so it'd mean either a small auth-go change or a pre-check wrapper. Want me to align it to a wider margin?
Run an adversarial Codex review through the shared plugin runtime. Position it as a challenge review that questions the chosen implementation, design choices, tradeoffs, and assumptions. It is not just a stricter pass over implementation defects.
Raw slash-command arguments: ``
Core constraint:
- This command is review-only.
- Do not fix issues, apply patches, or suggest that you are about to make changes.
- Your only job is to run the review and return Codex's output verbatim to the user.
- Keep the framing focused on whether the current approach is the right one, what assumptions it depends on, and where the design could fail under real-world conditions.
Execution mode rules:
- If the raw arguments include
--wait, do not ask. Run in the foreground. - If the raw arguments include
--background, do not ask. Run in a Claude background task. - Otherwise, estimate the review size before asking:
- For working-tree review, start with
git status --short --untracked-files=all. - For working-tree review, also inspect both
git diff --shortstat --cachedandgit diff --shortstat. - For base-branch review, use
git diff --shortstat <base>...HEAD. - Treat untracked files or directories as reviewable work for auto or working-tree review even when
git diff --shortstatis empty. - Only conclude there is nothing to review when the relevant scope is actually empty.
- Recommend waiting only when the scoped review is clearly tiny, roughly 1-2 files total and no sign of a broader directory-sized change.
- In every other case, including unclear size, recommend background.
- When in doubt, run the review instead of declaring that there is nothing to review.
- For working-tree review, start with
- Then use
AskUserQuestionexactly once with two options, putting the recommended option first and suffixing its label with(Recommended):Wait for resultsRun in background
Argument handling:
- Preserve the user's arguments exactly.
- Do not strip
--waitor--backgroundyourself. - Do not weaken the adversarial framing or rewrite the user's focus text.
- The companion script parses
--waitand--background, but Claude Code'sBash(..., run_in_background: true)is what actually detaches the run. /codex:adversarial-reviewuses the same review target selection as/codex:review.- It supports working-tree review, branch review, and
--base <ref>. - It does not support
--scope stagedor--scope unstaged. - Unlike
/codex:review, it can still take extra focus text after the flags.
Foreground flow:
- Run:
- Return the command stdout verbatim, exactly as-is.
- Do not paraphrase, summarize, or add commentary before or after it.
- Do not fix any issues mentioned in the review output.
Background flow:
- Launch the review with
Bashin the background:
- Do not call
BashOutputor wait for completion in this turn. - After launching the command, tell the user: "Codex adversarial review started in the background. Check
/codex:statusfor progress."
Codex Status
Session runtime: direct startup Review gate: disabled
No jobs recorded yet.
If the user did not pass a job ID:
- Render the command output as a single Markdown table for the current and past runs in this session.
- Keep it compact. Do not include progress blocks or extra prose outside the table.
- Preserve the actionable fields from the command output, including job ID, kind, status, phase, elapsed or duration, summary, and follow-up commands.
If the user did pass a job ID:
- Present the full command output to the user.
- Do not summarize or condense it.
| Job ID | Kind | Status | Phase | Elapsed | Summary | Follow-up |
|---|---|---|---|---|---|---|
b248fos53 | adversarial-review | 🟡 running | exploring (reading auth/refresh/tokenmanager sources) | ~in-flight | No verdict yet — Codex is still inspecting files (~9.4KB progress) | Re-run /codex:status shortly; I'll surface the verbatim review when it completes |
Still working — it's read the login/auth/contexts/tokenmanager/provider files and is mapping CurrentProvider/client config. No findings emitted yet. I'll relay Codex's output verbatim once it finishes.
Codex adversarial review (verbatim):
Codex Adversarial Review
Target: branch diff against main Verdict: needs-attention
No-ship: the refresh-token change introduces a loopback/dev auth regression, leaves refresh bearers behind on logout, and persists rotated credentials non-atomically in a way that can strand or revoke sessions after partial failures.
Findings:
- [high]
entire logoutno longer clears all bearer material (cmd/entire/cli/auth/context_store.go:35-75) This branch starts persisting refresh tokens in a separate:refreshkeychain slot, but the logout paths still delete only the access-token slot. Afterentire logoutorentire logout --all, the long-lived refresh token remains in the OS keyring. The CLI stops resolving the context, but the machine is not actually scrubbed of credentials anymore; any later code path or local process with keyring access can still mint fresh access tokens from that leftover refresh token. Recommendation: When removing a context, delete bothKeychainServiceandKeychainService+":refresh"for the handle, and add logout tests that assert both slots are gone. - [high] Rotated refresh credentials are written non-atomically (cmd/entire/cli/auth/refresh.go:63-82)
contextTokenStore.SaveTokensoverwrites the access token first and only then stores the rotated refresh token. If the second keyring write fails, the store is left with a new access token paired with the now-stale refresh token. This file explicitly relies on single-use refresh-token rotation; replaying that stale token on the next refresh can triggerinvalid_grant/family revocation and force a full re-login after what should have been a transient local write failure. Recommendation: Persist the rotated refresh token before replacing the access token, or store both values as one atomic bundle / roll back the access-token write on refresh-slot failure. - [high] Loopback HTTP cores now fail before any git operation starts (cmd/git-remote-entire/main.go:79-115)
git-remote-entirenow always constructs a refreshing login provider and passesskipTLSas theAllowInsecureHTTPknob. That is the wrong gate for local loopback cores:skipTLSis about certificate verification, while plainhttp://127.0.0.1/http://localhostdevelopment auth previously worked without it.tokenmanager.Newrejects insecure issuers unlessAllowInsecureHTTPis true, so clone/fetch/push against loopback dev cores will now die up front even when the cached login JWT is still fresh. Recommendation: Mirror the existing auth behavior and auto-allow loopback HTTP (isLoopbackHTTP(clusterCtx.CoreURL)) independently ofENTIRE_TLS_SKIP_VERIFY, or plumb a dedicated insecure-auth flag instead of reusing the TLS-skip setting.
Next steps:
- Fix logout/context removal to delete paired refresh-token slots and cover it with tests.
- Make refresh persistence atomic across access+refresh token updates, or add rollback on partial keyring failure.
- Restore loopback HTTP compatibility for git-remote-entire before shipping the refresh provider change.
as for the loopback HTTP thing - in practice our dev setup uses mkcert with https, so we should be fine. have we touched any skiptls config? hopefully not.
Confirmed — we did not touch any skipTLS config or its semantics.
The diff vs origin/main shows exactly one new line referencing it:
Everything else is untouched:
skipTLS := os.Getenv("ENTIRE_TLS_SKIP_VERIFY") == "true"— unchangedhttpClient.Transport = httpclient.NewTransport(skipTLS)— unchangedtransport.Config{SkipTLS: skipTLS}— unchanged
So it's purely additive: we reused the existing skipTLS value as the new provider's allowInsecureHTTP gate. No change to certificate verification behavior.
And your mkcert point is right — the finding is moot for your dev setup. tokenmanager.New only requires AllowInsecureHTTP=true for an http:// issuer; an https:// core (mkcert) passes validation regardless of that flag. So with mkcert you have skipTLS=false and an https core → login/refresh constructs fine. Codex's regression only bites a plain http://127.0.0.1 core, which you don't use.
Worth being precise though: it's a real (if narrow) behavior change for anyone on a non-TLS loopback core — they'd now need ENTIRE_TLS_SKIP_VERIFY=true to get refresh working, where before plain-http auth "just worked." Cheap to neutralize later (auto-allow isLoopbackHTTP(coreURL) independent of the TLS-skip env) if you ever want to keep that path open, but not a blocker for your setup.
The other two Codex findings are independent of TLS and still stand: logout leaving the :refresh slot behind, and the non-atomic access-then-refresh write. Want me to act on either of those?