Debug Cluster Context Git Remote Lookup

Claude Code·Opus 4.8[1m]·toothbrush·3mo ago·28hr 48min·14 Checkpoints·58 file changes·+1710/-927·1.7M tokens

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?

3mo ago·3m

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-1 entry was not written by git pull. This CLI's git-remote-entire deliberately never persists cluster_contexts. Something else wrote it — almost certainly entire-core (the entiredb CLI that shares contexts.json) or an explicit entire-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 unbind but no bind.

What actually happens on git pull

ResolveContextForCluster (internal/entireclient/clusterdiscovery/resolve.go:42) resolves per-invocation:

  1. If cluster_contexts[host] exists → use it.
  2. Else → fetch the cluster's /.well-known/entire-cluster.json, match advertised core_urls against your local contexts, prefer current_context among matches. Result is ephemeral — never written back (resolve.go:23, with a long comment on why).
  3. No match → fatal error with a login hint. There is deliberately no current_context fallback 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-known advertised a core_url matching us.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

OptionWhatRisk
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 discoveryMake 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 clustersLet 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.

3mo ago·2m

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:

  1. 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() POSTs subject_token=loginJWT to c.coreURL (repocreds.go:175, 181). The cluster host never sees it.
  2. Repo-scoped token — minted by that exchange with audience = clusterURL + "/et/owner/repo" and scope = 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_urls against 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_host and 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-known keeps 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_contexts is a division of labor: entire-core owns 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?

let's start by rewriting the comments to speak the truth indeed. then commit.

3mo ago·53.7s

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.

3mo ago·28.4s

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.

3mo ago·18m

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's CoreURL, 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 adjacent current_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 lint has one failure — a pre-existing shellcheck SC2312 in mise-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.

have a look at timeouts in cmd/git-remote-entire. list out any relevant ones for me

3mo ago·1m

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.

#ValueWhereApplies toNotes
130s total requestmain.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.
2none (no Timeout)proxy.go:107 (p.client)the git data transfer — info/refs, git-upload-pack, git-receive-packDeliberate: a total timeout would kill big clone/fetch/push. Bounded instead by #3 (connect) + parent ctx (signals). No idle/stall timeout, though.
3500ms TCP connect (DefaultDialTimeout), env override ENTIRE_CONNECT_TIMEOUT_SECONDShttpclient/transport.go:23,28every 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.
42s gracemain.go:186 (installSignalstime.Sleep)shutdown after first SIGINT/SIGTERMNot a network timeout. After 2s prints "shutdown taking longer…"; second Ctrl-C hard-exits. Cancelling ctx aborts in-flight transfers.
51m SafetyMargin (capped at TTL/2)repocreds.go:31,127scoped-token cache rotationFreshness 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.

3mo ago·1m

Done. Committed as d026319e1 (not pushed).

  • DefaultDialTimeout 500ms → 1s in httpclient/transport.go, comment updated to explain the slow-initial-connect case.
  • No hardcoded 500 in tests — they reference the symbol, so they still pass. fmt + go lint clean.
  • ENTIRE_CONNECT_TIMEOUT_SECONDS override 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:

  1. 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.

  2. 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

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.

3mo ago·2m

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

#ConcernOriginal (entiredb)OursImpact
1Persist binding on discovery matchresolve.go calls BindCluster inlineremoved — replaced with "ephemeral; binding not persisted" debug logEvery pull re-runs /.well-known; bindings never accumulate (your symptom)
2Post-success auto-bindScopedAuth.onFirstSuccessBindClusterno ScopedAuth wrapper; repocreds.New called directly, no success hookno binding written even after proven auth
3makeScopedSuccessHook in mainpresent, wired into scoped authabsent
4resolve.go doc/intent"binding written so next call skips discovery""this helper does not persist … correctness choice"our comment actively asserts the opposite behavior
5ENTIRE_TOKEN env override (CI/workload identity)resolveCredentials handles it via CoreURLFromEnvToken (reads aud)absent — we go straight to MigrateLegacyLoginContext + resolveCI/automation token flow unsupported
6Token refreshclient.GetTokenWithRefresh (refresh token from keyring)auth.LoginTokenForContext — read-only, no refresh; 401 → re-loginexpired 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 BindCluster after 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 onFirstSuccess hook to repocreds.Cache.Token (or wrap it), fire BindCluster after 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?

Yes let's do B, just the safe/conservative version. Commit early and often.

3mo ago·5m

Done. Two commits on 20260602-auth-contexts (not pushed):

  • 823f0349c — behavior + comment reconciliation
  • 4f44cc845 — tests

What changed (option B, conservative):

  • git-remote-entire/main.go: new makeBindHook — a sync.Once-guarded closure that calls contexts.BindCluster and fires from setAuth after 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 that auth use affects 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?

3mo ago·1m

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):

  1. cluster_contexts["cluster1"] names one context → returned as-is, discovery skipped, active context irrelevant. Whichever got bound first wins, permanently, until you auth unbind or 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 both paul@core-us and admin@core-us, in on-disk order (confirmed: contexts.go:118 iterates f.Contexts in 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@unrelated is on a different core → not in matches → tie-break does nothing.
  • Returns matches[0] = first-logged-in account on core-us.
  1. On the first successful scoped-token exchange, makeBindHook persists cluster1 → 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 contexts shows the binding (cluster1 -> admin@core-us).
  • Fix: entire auth unbind cluster1, then entire auth use paul@core-us, then re-run — but careful: after unbind, the next op re-runs discovery, and since paul@core-us still isn't your active context unless you switched, you'd again get matches[0]. The reliable fix is an explicit bind. We removed inline persistence but there's no entire auth bind command (only unbind) — so today you can't deterministically pin cluster1 → paul@core-us without editing contexts.json by 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".
3mo ago·19m

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):

  1. Active context eligible (current.CoreURL ∈ core_urls) → use it.
  2. 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 existing contexts.json (incl. your real aws-eu-central-1 → us.auth.entire.io) loads without error.
  • On first op against any cluster, we fetch /.well-known and 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_contexts inert in contexts.json rather 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)

  1. New clustercores cache package (file I/O, TTL, stale-fallback) + unit tests — self-contained, no wiring.
  2. SelectContext + rewrite ResolveContextForCluster to cores-cache + selection; ambiguity error + tests. (resolve behavior changes here.)
  3. Strip persistence from git-remote-entire (makeBindHook/hook removal).
  4. Remove bind/unbind command surface; adjust auth contexts; clear cache in RemoveAllContexts.
  5. 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 contexts output: 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.

3mo ago·18m

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)

  1. discovery: TTL'd cluster_cores.jsonhost → {core_urls, fetched_at}, 7d TTL, stale-fallback on fetch failure, in the cache dir beside nodes.json. Shared lock/atomic-IO factored out (no dup).
  2. 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. Dropped makeBindHook.
  3. auth: remove the binding surfaceBindCluster, File.Resolve, ClusterBindings, UnbindCluster, entire auth unbind, and the "Cluster bindings" section of entire auth contexts.
  4. 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

  • ClusterContexts field kept but inert (deprecated comment) — old contexts.json round-trips, no data loss, no clobbering during the entiredb transition. Delete still 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:go clean (0 issues).
  • Pre-existing shellcheck SC2312 in mise-tasks/release still fails full mise run lint — unrelated, untouched.

Not done (deferred / yours)

  • Two-views consolidation (auth list vs auth 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?

3mo ago·6m

Both done. Bottom line:

  • Dropped ClusterContexts entirely — safe because contexts.go uses plain json.Unmarshal (no DisallowUnknownFields), so an old contexts.json with cluster_contexts still 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 in Delete/RemoveAllContexts.
  • Delete now always clears current_context when the active context is deleted — no fallback to Contexts[0]. Updated the test (TestDelete_OfCurrentClearsCurrent) and RemoveCurrentContext (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.

3mo ago·54.0s

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:

  1. It could silently authenticate as the wrong account. When more than one local context was eligible for a cluster (e.g. alice@core and admin@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 as admin@core with no prompt and no error — and then freeze that arbitrary choice by persisting it as a cluster→context binding.
  2. 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.
  3. Deleting your active context silently switched identity. Delete advanced current_context to 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'd cluster_cores.json (memoizes the /.well-known discovery, 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_context rather than switching you to another identity.
  • Removed the now-meaningless bind/unbind surface (entire auth unbind and the "Cluster bindings" section of entire 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-known lookup 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.json loads unchanged. The obsolete cluster_contexts key 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

3mo ago·14.6s

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-known is cached; first op per cluster fetches once, as before.
  • No migration / re-login — existing contexts.json loads as-is; the obsolete cluster_contexts key 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

3mo ago·3m

Fixed and committed (0feecb4b0). Push it and CI should go green.

Bottom line:

  • Root cause: the generic readCacheFile[T] returned its type param T, which ireturn flags. The //nolint:ireturn I'd added kept getting auto-stripped by the local golangci-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, returns error (which ireturn permits). Same shared read/unmarshal/corrupt-reset logic, no suppression directive to lose.
  • Verified: go build ./..., discovery tests pass, and a full golangci-lint run (no --fix, like CI) reports 0 issues — so no ireturn and no dupl regression from the two now-tiny readers.

ok push.

3mo ago·5m

Pushed — 7b3a2bbf0..e67d7e189 on 20260602-auth-contexts.

  • Rebased my ireturn fix on top of the remote's Copilot comment-reword commit (no conflicts), then pushed.
  • The entire/checkpoints/v1 sync 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.

3mo ago·20.5s

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:

  1. No ENTIRE_TOKEN env override. The original resolveCredentials honors ENTIRE_TOKEN (CI / workload-identity), deriving the core URL from the JWT's aud claim via CoreURLFromEnvToken — skipping contexts.json/keyring entirely. Ours goes straight to context resolution, so the CI/automation bearer-token flow isn't supported.

  2. No token refresh. The original mints via client.GetTokenWithRefresh (refresh token from the keyring). Ours uses auth.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.

write a small handover for 1 & 2 here, inline, and i'll get someone else to do it.

3mo ago·47.7s

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.goresolveCredentials(): checks ENTIRE_TOKEN first; 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.goCoreURLFromEnvToken() + jwtAudiences(): reads the URL-shaped aud claim (not iss). Login/SA-session JWTs carry aud=<home-region URL>, which is what STS routing keys on. aud can be a string or array (RFC 7519) — handle both.

Where in our repo (cli):

  • cmd/git-remote-entire/main.gorun(): add the ENTIRE_TOKEN branch before MigrateLegacyLoginContext / ResolveContextForCluster. When set, skip both and build repocreds.New(coreURL, clusterBaseURL, staticProvider, httpClient) where staticProvider just returns the env token and coreURL comes from its aud.
  • We already parse JWTs via github.com/entireio/auth-go/tokens (tokens.ParseClaims, used in cmd/entire/cli/auth/contexts.go). Check whether it exposes the audience; if not, port entiredb's small jwtAudiences helper.

Acceptance:

  • ENTIRE_TOKEN=<jwt> git clone entire://host/... works with no contexts.json and 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.goresolveCredentials() uses client.GetTokenWithRefresh(ctx, AuthRefreshConfig{KeyringService, BaseURL, Username, CoreBaseURL, HTTPClient, ClientID}).

Where in our repo (cli):

  • cmd/entire/cli/auth/contexts.goLoginTokenForContext() 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 LoginJWTProvider passed to repocreds.New in git-remote-entire/main.go is where a refreshing provider would slot in — repocreds already 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:

  1. Login sidelogin.go / the device-flow exchange must request + store a refresh token (and client_id) in the keyring alongside the access token.
  2. Use side — replace the static LoginTokenForContext provider 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.

is it reasonable to have refresh tokens for device auth flow?

3mo ago·31.9s

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-entire calls client.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):

  1. Server issues them — typically gated on the offline_access scope in the device-auth request. entiredb's path implies it's already enabled.
  2. 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.
  3. 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

3mo ago·24m

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 (DeviceAuthPollwaitForApprovalrunLogin); persist it to the <service>:refresh keyring slot in RecordLoginContext.
  • Refresh-aware provider: NewRefreshingLoginProvider wires a tokenmanager.Manager (already a dependency) via a ~90-line per-context tokenstore.Store adapter; git-remote-entire uses 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?

did you inspect whta's in ~/src/entirehq/entiredb? are we diverging much? is ours better?

3mo ago·3m

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 refreshNone. 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 raceOn 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 ownershipBespoke 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)

  1. 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.)
  2. entr_ prefix guard. entiredb checks the refresh token looks core-issued before POSTing, short-circuiting stale non-core tokens locally instead of via a server invalid_grant. Minor — ours just gets the server rejection.
  3. Better re-login hint. entiredb says entire-core auth login --base-url <core> (copy-pasteable, core-specific); ours says run 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?

yes i want the relogin hint.

3mo ago·2m

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?

3mo ago·32.8s

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. Request offline_access at 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_token to <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":

    1. Concurrency. entiredb's GetTokenWithRefresh has no lock. Ours goes through auth-go tokenmanager (cross-process file lock + single-flight + rotation-race retry). Under parallel git-remote-entire processes (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.
    2. The login side is genuinely new here. entiredb's git-remote-entire consumes refresh tokens that its operator login (entire-core auth login, loopback PKCE) persisted. The entire-cli device-flow login — which lives in our repo — never requested offline_access or 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.

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).

ok push and draft PR

3mo ago

actually wait. git fetch --all and rebase on origin/main, then push and draft PR

3mo ago·13m

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/

3mo ago·42.8s

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 --cached and git 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 --shortstat is 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.
  • Then use AskUserQuestion exactly once with two options, putting the recommended option first and suffixing its label with (Recommended):
    • Wait for results
    • Run in background

Argument handling:

  • Preserve the user's arguments exactly.
  • Do not strip --wait or --background yourself.
  • Do not weaken the adversarial framing or rewrite the user's focus text.
  • The companion script parses --wait and --background, but Claude Code's Bash(..., run_in_background: true) is what actually detaches the run.
  • /codex:adversarial-review uses the same review target selection as /codex:review.
  • It supports working-tree review, branch review, and --base <ref>.
  • It does not support --scope staged or --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 Bash in the background:
  • Do not call BashOutput or wait for completion in this turn.
  • After launching the command, tell the user: "Codex adversarial review started in the background. Check /codex:status for progress."
3mo ago·43.3s

Codex adversarial review started in the background. Check /codex:status for 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.
3mo ago·20.0s
Job IDKindStatusPhaseElapsedSummaryFollow-up
b248fos53adversarial-review🟡 runningexploring (reading auth/refresh/tokenmanager sources)~in-flightNo 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.

3mo ago·32.2s

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 logout no longer clears all bearer material (cmd/entire/cli/auth/context_store.go:35-75) This branch starts persisting refresh tokens in a separate :refresh keychain slot, but the logout paths still delete only the access-token slot. After entire logout or entire 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 both KeychainService and KeychainService+":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.SaveTokens overwrites 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 trigger invalid_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-entire now always constructs a refreshing login provider and passes skipTLS as the AllowInsecureHTTP knob. That is the wrong gate for local loopback cores: skipTLS is about certificate verification, while plain http://127.0.0.1 / http://localhost development auth previously worked without it. tokenmanager.New rejects insecure issuers unless AllowInsecureHTTP is 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 of ENTIRE_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.

3mo ago·39.9s

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" — unchanged
  • httpClient.Transport = httpclient.NewTransport(skipTLS) — unchanged
  • transport.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?