Skip to content

Commit 46974b5

Browse files
author
gluck
committed
auth: a vault call that finds the agent gone brings it back
hush's agent exits when its TTL runs out, and that TTL is absolute: counted from the agent's start, never extended by use. figaro sets it to a day, so every agent dies exactly one day after it was born (the log on the author's box shows it nightly: 01:36, 01:41, 01:42, creeping by the keep-alive's delay). Until something starts a new one, every vault call fails with "dial unix .../agent.sock: connect: no such file or directory". Two things used to start a new one: any CLI command (ensureHush on the way to the daemon), and keepHushAlive, on a ticker of up to five minutes. A turn that starts without a CLI command (the next round of a turn already running, a queued message, a prompt typed into a long-lived pager, a message from another aria or a bridge) landed in the gap and failed. Since the Copilot session token stopped being cached in figaro (c59f63a), every Copilot round asks the agent, so every round in the gap failed: copilot: resolve token: dial unix /tmp/figaro-hush/agent.sock: connect: no such file or directory seen on two boxes, and then explained to the user as a rate-limited token exchange, which it was not: the credential never left the vault. The repair is at the point of use. auth.WithAgent runs a vault call and, if it failed because no agent serves the socket (ENOENT, ECONNREFUSED: the real client's error, matched with errors.Is), revives the agent and asks once more. The OAuth strategy (get and refresh) and the encrypted API key take a Revive, wired to ensureHushAgent, which serializes EnsureReady: managed's own ping-then-spawn has no lock, and the keep-alive and every provider goroutine now arrive at it together. A failed revival is reported as such (ReviveError), and authFailureHint names the vault for it, and for the raw dial error from older builds, instead of the exchange endpoint. Reproduced end to end in an isolated box (own vault via FIGARO_HUSH_DIR, a 45 s FIGARO_HUSH_TTL, a fake Copilot playing both the exchange and the API through base_url, no CLI command during the wait): a turn whose tool sleeps past the agent's deadline. On v0.38.2 round two failed with the message above, every time; on this build the agent was revived at the moment of the call, ten seconds before the keep-alive's tick, and the turn finished. Unit tests use the real hush client against a socket with nothing behind it, so AgentGone is checked against the shape production sees; the strategy's revival and the hint were each canaried.
1 parent e1a8f14 commit 46974b5

7 files changed

Lines changed: 284 additions & 10 deletions

File tree

‎internal/auth/revive.go‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package auth
2+
3+
import (
4+
"errors"
5+
"syscall"
6+
)
7+
8+
// THE AGENT IS MORTAL, AND IT DIES ON A SCHEDULE.
9+
//
10+
// hush's agent exits when its TTL runs out, and that TTL is ABSOLUTE: counted
11+
// from the moment it started, never extended by use. figaro sets it to a day,
12+
// so every agent dies exactly one day after it was born, and every request in
13+
// flight against its socket then fails with "connect: no such file or
14+
// directory" until something starts a new one. The daemon's keep-alive did,
15+
// on a ticker of up to five minutes, which made a daily outage of up to five
16+
// minutes in which every turn of every aria whose credential lives in the
17+
// vault failed (Gluck, 2026-10-12, two boxes). It reaches the agent dying of
18+
// anything else the same way: a crash, a stop in a dev shell sharing the
19+
// surface.
20+
//
21+
// The repair is at the point of use. A vault call that finds no agent brings
22+
// one up and asks once more. Starting the embedded agent is a spawn and an
23+
// unlock from the keyring or the passphrase file, so the turn pays well under
24+
// a second, once a day, instead of failing.
25+
26+
// AgentGone reports whether a hush call failed because no agent is serving
27+
// the socket: the socket file is missing, or nothing is listening on it. Any
28+
// other failure is the agent's answer and is not retried.
29+
func AgentGone(err error) bool {
30+
return errors.Is(err, syscall.ENOENT) || errors.Is(err, syscall.ECONNREFUSED)
31+
}
32+
33+
// WithAgent runs call, and if it failed because the agent is gone, runs
34+
// revive and call once more. A nil revive, or a revive that fails, returns
35+
// the original error: the caller's own message stays the diagnosis, with the
36+
// revival failure joined to it.
37+
func WithAgent[T any](revive func() error, call func() (T, error)) (T, error) {
38+
out, err := call()
39+
if err == nil || revive == nil || !AgentGone(err) {
40+
return out, err
41+
}
42+
if rerr := revive(); rerr != nil {
43+
var zero T
44+
return zero, errors.Join(err, &ReviveError{Err: rerr})
45+
}
46+
return call()
47+
}
48+
49+
// ReviveError is a failure to bring the agent back. It is its own type so a
50+
// message for the user can say "the vault agent would not start" instead of
51+
// blaming whatever the call was trying to do.
52+
type ReviveError struct{ Err error }
53+
54+
func (e *ReviveError) Error() string { return "hush agent would not start: " + e.Err.Error() }
55+
func (e *ReviveError) Unwrap() error { return e.Err }

‎internal/auth/revive_test.go‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package auth
2+
3+
import (
4+
"errors"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
9+
hush "github.com/jack-work/hush/client"
10+
)
11+
12+
// goneErr is the error a real hush client returns when no agent serves its
13+
// socket: the exact shape Gluck's turns failed with, produced by the real
14+
// client rather than spelled by hand.
15+
func goneErr(t *testing.T) error {
16+
t.Helper()
17+
c := hush.NewWithSocket(filepath.Join(t.TempDir(), "agent.sock"))
18+
_, _, err := c.OAuthGetFull("copilot")
19+
if err == nil {
20+
t.Fatal("a client with no agent behind it answered")
21+
}
22+
if !strings.Contains(err.Error(), "no such file or directory") {
23+
t.Fatalf("unexpected shape for a missing agent: %v", err)
24+
}
25+
return err
26+
}
27+
28+
func TestAgentGoneRecognisesTheRealClientError(t *testing.T) {
29+
if !AgentGone(goneErr(t)) {
30+
t.Fatal("the error a missing agent produces is not recognised as a missing agent")
31+
}
32+
for _, other := range []error{
33+
hush.ErrOAuthNotFound,
34+
errors.New("oauth refresh rejected"),
35+
nil,
36+
} {
37+
if AgentGone(other) {
38+
t.Fatalf("%v was taken for a missing agent", other)
39+
}
40+
}
41+
}
42+
43+
// A call that finds the agent gone revives it ONCE and asks again; the turn
44+
// sees only the second answer.
45+
func TestWithAgentRevivesAndRetriesOnce(t *testing.T) {
46+
gone := goneErr(t)
47+
calls, revives := 0, 0
48+
got, err := WithAgent(func() error { revives++; return nil }, func() (string, error) {
49+
calls++
50+
if calls == 1 {
51+
return "", gone
52+
}
53+
return "token", nil
54+
})
55+
if err != nil || got != "token" {
56+
t.Fatalf("got (%q, %v), want the token from the retry", got, err)
57+
}
58+
if calls != 2 || revives != 1 {
59+
t.Fatalf("%d calls and %d revives, want 2 and 1", calls, revives)
60+
}
61+
}
62+
63+
// Anything that is not a missing agent is the agent's answer, and is not
64+
// second-guessed; nor is a missing agent retried with nothing to revive it.
65+
func TestWithAgentLeavesOtherFailuresAlone(t *testing.T) {
66+
revives := 0
67+
revive := func() error { revives++; return nil }
68+
_, err := WithAgent(revive, func() (string, error) { return "", hush.ErrOAuthNotFound })
69+
if !errors.Is(err, hush.ErrOAuthNotFound) || revives != 0 {
70+
t.Fatalf("a real answer was retried (revives=%d, err=%v)", revives, err)
71+
}
72+
gone := goneErr(t)
73+
calls := 0
74+
_, err = WithAgent[string](nil, func() (string, error) { calls++; return "", gone })
75+
if calls != 1 || !AgentGone(err) {
76+
t.Fatalf("with no reviver: %d calls, err %v; want one call and the original error", calls, err)
77+
}
78+
}
79+
80+
// A revival that fails keeps the original error AND says the agent would not
81+
// start, so the message for the user can name the vault, not the provider.
82+
func TestWithAgentReportsAFailedRevival(t *testing.T) {
83+
gone := goneErr(t)
84+
_, err := WithAgent(func() error { return errors.New("unlock figaro: keyring locked") }, func() (string, error) {
85+
return "", gone
86+
})
87+
var rev *ReviveError
88+
if !errors.As(err, &rev) || !AgentGone(err) {
89+
t.Fatalf("want both the dial error and a ReviveError, got %v", err)
90+
}
91+
if !strings.Contains(err.Error(), "hush agent would not start: unlock figaro: keyring locked") {
92+
t.Fatalf("message does not name the revival: %v", err)
93+
}
94+
}
95+
96+
// The strategy itself, end to end through the real client: the first ask
97+
// finds no agent, Revive is called, and the error that reaches the caller
98+
// after a failed revival is the one the hint recognises.
99+
func TestOAuthRevivesThroughTheRealClient(t *testing.T) {
100+
revives := 0
101+
o := &OAuth{
102+
Hush: hush.NewWithSocket(filepath.Join(t.TempDir(), "agent.sock")),
103+
Name: "copilot",
104+
Revive: func() error { revives++; return errors.New("no identity") },
105+
}
106+
_, ok, err := o.TryResolve()
107+
if ok || err == nil {
108+
t.Fatalf("resolved with no agent: ok=%v err=%v", ok, err)
109+
}
110+
if revives != 1 {
111+
t.Fatalf("the strategy tried to revive %d times, want 1", revives)
112+
}
113+
if !strings.Contains(err.Error(), "hush agent would not start") {
114+
t.Fatalf("the failed revival is not in the error: %v", err)
115+
}
116+
}

‎internal/auth/strategy.go‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ type OAuth struct {
9696
Hush *hush.Client
9797
Name string
9898

99+
// Revive brings the agent back when a call finds it gone (see
100+
// WithAgent). Nil means a missing agent is an error, as it always was.
101+
Revive func() error
102+
99103
// endpoint is the routing minted alongside the token, for credentials
100104
// whose exchange chooses the host (Copilot). Published atomically
101105
// because Resolve runs on provider goroutines and Endpoint is read by
@@ -107,7 +111,15 @@ func (o *OAuth) TryResolve() (string, bool, error) {
107111
if o.Hush == nil || o.Name == "" {
108112
return "", false, nil
109113
}
110-
tok, meta, err := o.Hush.OAuthGetFull(o.Name)
114+
type full struct {
115+
tok string
116+
meta map[string]string
117+
}
118+
got, err := WithAgent(o.Revive, func() (full, error) {
119+
tok, meta, err := o.Hush.OAuthGetFull(o.Name)
120+
return full{tok, meta}, err
121+
})
122+
tok, meta := got.tok, got.meta
111123
if err != nil {
112124
if errors.Is(err, hush.ErrOAuthNotFound) {
113125
return "", false, nil
@@ -151,7 +163,10 @@ func (o *OAuth) Invalidate(token string) error {
151163
if o.Hush == nil || o.Name == "" {
152164
return nil
153165
}
154-
_, meta, err := o.Hush.OAuthRefreshFull(o.Name)
166+
meta, err := WithAgent(o.Revive, func() (map[string]string, error) {
167+
_, meta, err := o.Hush.OAuthRefreshFull(o.Name)
168+
return meta, err
169+
})
155170
if err == nil {
156171
o.rememberEndpoint(meta)
157172
return nil

‎internal/cli/angelus.go‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"os/signal"
99
"path/filepath"
1010
"runtime/debug"
11+
"sync"
1112
"syscall"
1213
"time"
1314

@@ -59,6 +60,23 @@ func incumbentAnswers() bool {
5960
}
6061

6162
// runAngelus runs the supervisor side of the binary.
63+
// hushEnsureMu serializes bringing the agent up. managed.EnsureReady pings
64+
// and then spawns, with no lock of its own, and since a dead agent is now
65+
// revived by whichever caller finds it dead (every provider goroutine
66+
// resolving a token, as well as the keep-alive), several of them arrive at
67+
// once. Serialized, the first spawns and the rest find it answering.
68+
var hushEnsureMu sync.Mutex
69+
70+
// ensureHushAgent makes sure the embedded agent is up, starting it if not. It
71+
// is the Revive of every vault-backed credential strategy (auth.WithAgent),
72+
// so a turn that lands in the gap after the agent's TTL brings the agent back
73+
// rather than failing until the keep-alive's next tick.
74+
func ensureHushAgent() error {
75+
hushEnsureMu.Lock()
76+
defer hushEnsureMu.Unlock()
77+
return mustHush().EnsureReady()
78+
}
79+
6280
// keepHushAlive pings the embedded hush agent on an interval and respawns it
6381
// if it has died (EnsureReady), so the token-refresh machinery survives for
6482
// the whole daemon session. Interval is well under the agent TTL so a dead
@@ -77,7 +95,7 @@ func keepHushAlive(ctx context.Context) {
7795
// `figaro stop` acts on, and a claim written before there is anything
7896
// to claim would name a socket that never existed.
7997
ensure := func(stage string) {
80-
if err := h.EnsureReady(); err != nil {
98+
if err := ensureHushAgent(); err != nil {
8199
slog.Warn("hush keep-alive: "+stage+" ensure failed", "err", err)
82100
return
83101
}

‎internal/cli/outfit_helpers.go‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ import (
2121
type encryptedAPIKey struct {
2222
Hush *hush.Client
2323
ConfigPath string
24+
// Revive brings the agent back when a decrypt finds it gone; see
25+
// auth.WithAgent.
26+
Revive func() error
2427

2528
mu sync.Mutex
2629
cached string
@@ -52,7 +55,9 @@ func (e *encryptedAPIKey) TryResolve() (string, bool, error) {
5255
if !strings.HasPrefix(pa.APIKey, "AGE-ENC[") {
5356
return "", false, nil
5457
}
55-
res, err := e.Hush.Decrypt(map[string]string{"v": pa.APIKey})
58+
res, err := auth.WithAgent(e.Revive, func() (map[string]string, error) {
59+
return e.Hush.Decrypt(map[string]string{"v": pa.APIKey})
60+
})
5661
if err != nil {
5762
return "", false, fmt.Errorf("hush decrypt %s: %w", e.ConfigPath, err)
5863
}

‎internal/cli/provider.go‎

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ func buildResolver(loaded *config.Loaded, providerName string) (auth.TokenResolv
5454
}
5555
// Returned bare, not wrapped in an Aggregate: it is the only
5656
// source, and the provider reads the routing off it directly.
57-
return &auth.OAuth{Hush: hushClient, Name: providerName}, nil
57+
return &auth.OAuth{Hush: hushClient, Name: providerName, Revive: ensureHushAgent}, nil
5858
}
5959

6060
strategies := environmentStrategies(reg)
@@ -70,12 +70,14 @@ func buildResolver(loaded *config.Loaded, providerName string) (auth.TokenResolv
7070
&encryptedAPIKey{
7171
Hush: hushClient,
7272
ConfigPath: loaded.ProviderAuthPath(providerName),
73+
Revive: ensureHushAgent,
7374
},
7475
)
7576
if hasOAuth {
7677
strategies = append(strategies, &auth.OAuth{
77-
Hush: hushClient,
78-
Name: providerName,
78+
Hush: hushClient,
79+
Name: providerName,
80+
Revive: ensureHushAgent,
7981
})
8082
}
8183
return &auth.Aggregate{Strategies: strategies}, nil
@@ -135,10 +137,43 @@ func sessionExchangeHint(reason string) string {
135137
return b.String()
136138
}
137139

138-
// authFailureHint picks the right explanation for a failed turn: no
139-
// credential at all, or a credential that could not be exchanged.
140+
// vaultAgentHint explains a credential that could not be READ, because the
141+
// hush agent that holds it was not running and could not be started. It used
142+
// to fall through to sessionExchangeHint, which told the user an exchange
143+
// endpoint was rate-limiting them while the credential had never left the
144+
// vault.
145+
func vaultAgentHint(reason string) string {
146+
var b strings.Builder
147+
b.WriteString(reason)
148+
if !strings.HasSuffix(reason, "\n") {
149+
b.WriteString("\n")
150+
}
151+
b.WriteString("\nfigaro's vault agent (hush) was not running, so the stored credential\n")
152+
b.WriteString("could not be read, and starting it again failed. Nothing was sent to the\n")
153+
b.WriteString("provider. To see why, and to start it by hand:\n")
154+
b.WriteString(" figaro vault status (identity, unlock method, agent)\n")
155+
b.WriteString(" figaro vault unlock\n")
156+
return b.String()
157+
}
158+
159+
// vaultAgentDown reports whether a failure reason is the vault agent being
160+
// unreachable rather than anything the provider said: the revival failing,
161+
// or (from a build that did not revive) the raw dial error on its socket.
162+
func vaultAgentDown(reason string) bool {
163+
if strings.Contains(reason, "hush agent would not start") {
164+
return true
165+
}
166+
return strings.Contains(reason, "agent.sock") &&
167+
(strings.Contains(reason, "no such file or directory") || strings.Contains(reason, "connection refused"))
168+
}
169+
170+
// authFailureHint picks the right explanation for a failed turn: the vault
171+
// agent unreachable, no credential at all, or a credential that could not be
172+
// exchanged.
140173
func authFailureHint(reason string) (string, bool) {
141174
switch {
175+
case vaultAgentDown(reason):
176+
return vaultAgentHint(reason), true
142177
case strings.Contains(reason, "resolve token"), strings.Contains(reason, "token exchange"):
143178
return sessionExchangeHint(reason), true
144179
case strings.Contains(reason, "no credential"):

‎internal/cli/provider_hint_test.go‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package cli
22

3-
import "testing"
3+
import (
4+
"strings"
5+
"testing"
6+
)
47

58
// The bug this splits: a 403 from GitHub's Copilot token-exchange endpoint
69
// arrived as "resolve token: copilot token exchange 403: <html>", figaro
@@ -45,3 +48,30 @@ func contains(hay, needle string) bool {
4548
return false
4649
})()
4750
}
51+
52+
// THE MESSAGE GLUCK GOT, verbatim: the vault agent's socket was missing, and
53+
// the hint blamed a rate-limited exchange endpoint for a credential that never
54+
// left the vault. It names the vault now, as does a failed revival.
55+
func TestAuthFailureHintNamesTheVaultWhenTheAgentIsGone(t *testing.T) {
56+
for _, reason := range []string{
57+
"copilot: resolve token: dial unix /tmp/figaro-hush/agent.sock: connect: no such file or directory",
58+
"resolve token: no credential available; first strategy error: dial unix /tmp/figaro-hush/agent.sock: connect: connection refused",
59+
"copilot: resolve token: dial unix /tmp/figaro-hush/agent.sock: connect: no such file or directory\nhush agent would not start: unlock figaro: keyring locked",
60+
} {
61+
hint, ok := authFailureHint(reason)
62+
if !ok {
63+
t.Fatalf("no hint for %q", reason)
64+
}
65+
if strings.Contains(hint, "rate-limits") || strings.Contains(hint, "exchanging it for a session token failed") {
66+
t.Fatalf("the vault agent's absence was blamed on the exchange endpoint:\n%s", hint)
67+
}
68+
if !strings.Contains(hint, "figaro vault status") {
69+
t.Fatalf("the hint does not point at the vault:\n%s", hint)
70+
}
71+
}
72+
// And a genuine exchange failure keeps the exchange hint.
73+
hint, _ := authFailureHint("copilot: resolve token: token exchange: 429 Too Many Requests")
74+
if !strings.Contains(hint, "exchanging it for a session token failed") {
75+
t.Fatalf("an exchange failure lost its hint:\n%s", hint)
76+
}
77+
}

0 commit comments

Comments
 (0)