Sibling of #509 (which makes a hook 401 tell the user to run kcap login). Once they do, a running daemon does not reliably pick the new credential up.
What is already fine
Worth stating so a fix does not touch it: every daemon HTTP path resolves a token per operation through TokenStore, which has no in-process cache — ServerConnection.ProcessEventQueueAsync (:1247), EvalRunner (:50, :99, :144), SpoolDrainLoop (:52), AgentOrchestrator (:262, :2824). Those see a new token on the next call. TokenRefreshLoop likewise drives the shared, cross-process-locked TokenStore.RefreshIfExpiringAsync, so proactive refresh is not the problem either.
A hub connection that fails to authenticate also self-heals: ConnectWithRetryAsync retries forever (backoff capped at 30s) and each StartHubAsync re-invokes AccessTokenProvider (ServerConnection.cs:192), which re-reads the store.
Problem 1 — a live hub connection pins its handshake identity
SignalR invokes AccessTokenProvider only at negotiate. kcap-server sets no CloseOnAuthenticationExpiration on the hub, so the ClaimsPrincipal captured at handshake is pinned for the life of the socket. A daemon whose token expires — or is replaced by kcap login — keeps operating as the old identity until something independently drops the transport.
This usually presents as silent staleness rather than an error, which makes it easy to miss: work attributed to the previous identity, or a tenant switch that appears not to take effect. ForceReconnectAsync (:785) already exists and is driven by DaemonHeartbeatLoop, so there is a natural hook — nothing currently triggers it on a credential change.
Problem 2 — a profile or tenant change never heals, and never explains itself
TokenStore.GetValidTokensForServerAsync resolves the active profile, then withholds the token entirely unless it is bound to the requested server:
if (!BoundToTarget(snapshot, targetBaseUrl)) {
return new(null, AuthStatus.WrongServer, snapshot.ServerUrl, profile);
}
The daemon always asks for its own configured _config.ServerUrl. So if kcap login lands on a different tenant (discovery lets you pick one), or the user runs kcap use to switch profiles, a daemon configured for the previous server gets WrongServer forever. No amount of retrying fixes a binding mismatch, but ConnectWithRetryAsync retries anyway, every 30s, in perpetuity — the daemon needs a restart or a reconfigure and nothing says so.
Diagnosability is the other half: the hub's AccessTokenProvider just returns null on WrongServer / Expired, discarding the reason. The daemon then logs a generic connection failure instead of "this profile's token was issued by " or "run kcap login" — the same unexplained-401 complaint as #509, one layer down.
Not yet reproduced
The originally-reported symptom ("after kcap login the daemon still used the old token") was an inference, not a confirmed observation, so the exact trigger is unverified. Both problems above are read off the code rather than from a repro. A third hypothesis worth testing while reproducing: a supervised daemon (launchd/systemd) runs with its own environment, so if HOME differs from the interactive shell's it would read a different token store entirely — that one is speculation, not verified.
Suggested first step: reproduce each path deliberately (expire a token under a live hub; kcap login into a second tenant with a daemon running; run the same under a supervised daemon) before choosing between "force a re-negotiate on credential change" and "detect the binding mismatch and report it instead of retrying forever". They may well need different fixes.
Sibling of #509 (which makes a hook 401 tell the user to run
kcap login). Once they do, a running daemon does not reliably pick the new credential up.What is already fine
Worth stating so a fix does not touch it: every daemon HTTP path resolves a token per operation through
TokenStore, which has no in-process cache —ServerConnection.ProcessEventQueueAsync(:1247),EvalRunner(:50,:99,:144),SpoolDrainLoop(:52),AgentOrchestrator(:262,:2824). Those see a new token on the next call.TokenRefreshLooplikewise drives the shared, cross-process-lockedTokenStore.RefreshIfExpiringAsync, so proactive refresh is not the problem either.A hub connection that fails to authenticate also self-heals:
ConnectWithRetryAsyncretries forever (backoff capped at 30s) and eachStartHubAsyncre-invokesAccessTokenProvider(ServerConnection.cs:192), which re-reads the store.Problem 1 — a live hub connection pins its handshake identity
SignalR invokes
AccessTokenProvideronly at negotiate. kcap-server sets noCloseOnAuthenticationExpirationon the hub, so theClaimsPrincipalcaptured at handshake is pinned for the life of the socket. A daemon whose token expires — or is replaced bykcap login— keeps operating as the old identity until something independently drops the transport.This usually presents as silent staleness rather than an error, which makes it easy to miss: work attributed to the previous identity, or a tenant switch that appears not to take effect.
ForceReconnectAsync(:785) already exists and is driven byDaemonHeartbeatLoop, so there is a natural hook — nothing currently triggers it on a credential change.Problem 2 — a profile or tenant change never heals, and never explains itself
TokenStore.GetValidTokensForServerAsyncresolves the active profile, then withholds the token entirely unless it is bound to the requested server:The daemon always asks for its own configured
_config.ServerUrl. So ifkcap loginlands on a different tenant (discovery lets you pick one), or the user runskcap useto switch profiles, a daemon configured for the previous server getsWrongServerforever. No amount of retrying fixes a binding mismatch, butConnectWithRetryAsyncretries anyway, every 30s, in perpetuity — the daemon needs a restart or a reconfigure and nothing says so.Diagnosability is the other half: the hub's
AccessTokenProviderjust returnsnullonWrongServer/Expired, discarding the reason. The daemon then logs a generic connection failure instead of "this profile's token was issued by " or "runkcap login" — the same unexplained-401 complaint as #509, one layer down.Not yet reproduced
The originally-reported symptom ("after
kcap loginthe daemon still used the old token") was an inference, not a confirmed observation, so the exact trigger is unverified. Both problems above are read off the code rather than from a repro. A third hypothesis worth testing while reproducing: a supervised daemon (launchd/systemd) runs with its own environment, so ifHOMEdiffers from the interactive shell's it would read a different token store entirely — that one is speculation, not verified.Suggested first step: reproduce each path deliberately (expire a token under a live hub;
kcap logininto a second tenant with a daemon running; run the same under a supervised daemon) before choosing between "force a re-negotiate on credential change" and "detect the binding mismatch and report it instead of retrying forever". They may well need different fixes.