Skip to content

Commit 4090e32

Browse files
fix(cli): persist login token durably on detached/headless boxes (#34)
`instant login` from a detached/headless context (e.g. `nohup instant login &` on an agent box) printed "✓ Logged in" while persisting NOTHING the next `instant whoami` could read — so the very next command said "Not logged in". This undercuts the "frictionless for agents" promise. Root cause: cliconfig.Save() trusted secretstore.Set()'s nil return as proof of durable persistence. On a detached box the keychain backend's Available() probe is a false positive (a probing Get returning ErrNotFound reads as "available, just empty"), and the subsequent Set returns nil even though the write lands in an ephemeral/locked session keyring the NEXT process can't read. Because Set didn't error, the file-fallback branch (FallbackAPIKey in ~/.instant-config, which Load() already reads) was never taken — no keychain entry survived, no file fallback written. Fix: - cliconfig: new persistSecret() does write-then-readback — after Set, immediately Get and confirm the value round-trips. A mismatch or read error is treated exactly like a Set failure, flipping Save() to the 0600 file fallback so the token persists where the read path looks. - login: if Save() errors (NEITHER keychain nor file accepted the token) runLogin no longer prints the success banner; it returns an error that carries the token + an `export INSTANT_TOKEN=…` escape hatch so a headless agent can still proceed in-flow. No more false success. Read path is unchanged: whoami / client auth go through cliconfig.Load(), which already consults the FallbackAPIKey field. Interactive/macOS desktop happy path is unchanged (durable keychain write → no on-disk plaintext, T16 P1-1 property preserved). Tests (100% patch coverage, diff-cover --fail-under=100): - cliconfig: silent-keychain-write (Set ok, Get mismatch) falls back to the 0600 file + survives Load; Set-error fallback; durable round-trip skips the file (no plaintext leak); persistSecret table of all outcomes. - cmd: Save-failure path asserts NO success banner + the INSTANT_TOKEN escape hatch + the token in the error. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 6c6a5df commit 4090e32

4 files changed

Lines changed: 297 additions & 5 deletions

File tree

cmd/login.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,14 +152,26 @@ func runLogin(cmd *cobra.Command, args []string) error {
152152
return err
153153
}
154154

155-
// Step 4: Save credentials.
155+
// Step 4: Save credentials. cfg.Save() durably persists the bearer token —
156+
// keychain when it's available AND the write survives an immediate
157+
// read-back, otherwise the 0600 ~/.instant-config file fallback (the path
158+
// that the next `instant whoami` / authenticated call reads). If Save
159+
// returns an error NEITHER store accepted the token: we MUST NOT print
160+
// "✓ Logged in" (the headless false-success this fix exists to kill).
161+
// Instead hand the user the token + an explicit `export INSTANT_TOKEN=…`
162+
// escape hatch so a headless agent can still proceed in-flow.
156163
cfg.APIKey = result.APIKey
157164
cfg.Email = result.Email
158165
cfg.Tier = result.Tier
159166
cfg.TeamName = result.TeamName
160167
cfg.APIBaseURL = APIBaseURL
161168
if err := cfg.Save(); err != nil {
162-
return fmt.Errorf("saving credentials: %w", err)
169+
return fmt.Errorf(
170+
"login succeeded but the API key could not be saved to the keychain "+
171+
"or to ~/.instant-config (%w).\nUse the token directly instead:\n"+
172+
" export INSTANT_TOKEN=%s\n"+
173+
"…then re-run your command. (Avoid committing the token.)",
174+
err, result.APIKey)
163175
}
164176

165177
fmt.Printf("\n✓ Logged in as %s (%s)\n", result.Email, result.Tier)

cmd/login_persist_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package cmd
2+
3+
// login_persist_test.go — guards the headless/detached login no-false-success
4+
// contract. The live dogfood bug: `instant login` from a detached/headless box
5+
// printed "✓ Logged in" while persisting NOTHING readable, so the next
6+
// `instant whoami` said "Not logged in". The durable-persist fix lives in
7+
// cliconfig.Save (keychain write-then-readback → file fallback); this file
8+
// pins the runLogin SURFACE: when neither store accepts the token (cfg.Save
9+
// errors), runLogin must NOT print the success banner and must instead hand
10+
// the user the token + an `export INSTANT_TOKEN=…` escape hatch.
11+
12+
import (
13+
"os"
14+
"path/filepath"
15+
"strings"
16+
"testing"
17+
)
18+
19+
// TestLogin_SaveFailure_NoFalseSuccess forces cfg.Save() to fail while leaving
20+
// cliconfig.Load() working, by pre-creating the atomic temp-write target
21+
// (~/.instant-config.tmp) as a DIRECTORY: os.WriteFile to that path errors,
22+
// but Load's read of the (absent) ~/.instant-config returns a clean empty
23+
// config. runLogin must then surface a hard error carrying the
24+
// export-INSTANT_TOKEN escape hatch and the token itself, and must NOT print
25+
// the "Logged in as" banner.
26+
func TestLogin_SaveFailure_NoFalseSuccess(t *testing.T) {
27+
withCleanState(t)
28+
29+
// HOME is already a fresh temp dir (withCleanState). Block ONLY the Save
30+
// temp-write by occupying ~/.instant-config.tmp with a directory.
31+
home, err := os.UserHomeDir()
32+
if err != nil {
33+
t.Fatalf("home dir: %v", err)
34+
}
35+
if err := os.Mkdir(filepath.Join(home, ".instant-config.tmp"), 0700); err != nil {
36+
t.Fatalf("seed temp-write blocker: %v", err)
37+
}
38+
39+
srv := claimLoginServer(t, nil, 0)
40+
defer srv.Close()
41+
withTestAPI(t, srv.URL)
42+
43+
var runErr error
44+
stdout, _ := captureStdout(t, func() {
45+
runErr = runLogin(nil, nil)
46+
})
47+
48+
if runErr == nil {
49+
t.Fatalf("expected runLogin to error when credentials can't be saved; stdout:\n%s", stdout)
50+
}
51+
if strings.Contains(stdout, loggedInPrefix) {
52+
t.Errorf("runLogin printed the success banner despite a failed save (false success); stdout:\n%s", stdout)
53+
}
54+
msg := runErr.Error()
55+
if !strings.Contains(msg, "export INSTANT_TOKEN=") {
56+
t.Errorf("error must offer the INSTANT_TOKEN escape hatch; got: %v", runErr)
57+
}
58+
if !strings.Contains(msg, claimTestAPIKey) {
59+
t.Errorf("error must include the token so a headless agent can proceed; got: %v", runErr)
60+
}
61+
}

internal/cliconfig/cliconfig.go

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,46 @@ func warnFileFallback() {
9696
})
9797
}
9898

99+
// secretGet/secretSet are package-level seams over the secretstore so tests
100+
// can inject a keychain that "succeeds" on Set but doesn't durably persist
101+
// (the headless/detached failure mode — see persistSecret). Production wires
102+
// them straight through; tests swap them in a t.Cleanup-guarded block.
103+
var (
104+
secretSet = secretstore.Set
105+
secretGet = secretstore.Get
106+
)
107+
108+
// persistSecret durably stores the bearer token, returning true iff it landed
109+
// in the OS keychain AND survives an immediate read-back. It returns false
110+
// (caller must use the on-disk file fallback) when the keychain is either
111+
// unavailable (Set errors) OR reports success but the value does NOT round-trip
112+
// on a fresh Get — the silent detached/headless failure mode that this whole
113+
// change exists to close.
114+
//
115+
// WHY THE READ-BACK. `instant login` from a detached/headless process (e.g.
116+
// `nohup instant login &` on an agent box) hits a keychain backend whose
117+
// Available() probe is a FALSE POSITIVE: a probing Get that returns "not found"
118+
// is read as "available, just empty", and the subsequent Set returns nil even
119+
// though the write landed in an ephemeral/locked session keyring the NEXT
120+
// process can't read. Set's nil return is therefore NOT proof of durability.
121+
// Verifying the write by reading it straight back is the only signal that
122+
// distinguishes a real keychain entry from a write that evaporates — and it is
123+
// what flips the file-fallback branch on so the token actually persists.
124+
func persistSecret(apiKey string) bool {
125+
if err := secretSet(apiKey); err != nil {
126+
// Keychain unavailable / no active backend — caller writes the file.
127+
return false
128+
}
129+
// Read-back verification: a keychain that accepted the write but can't
130+
// return the same value (detached session, locked collection) is NOT a
131+
// durable store. Treat a mismatch or read error exactly like a Set failure.
132+
got, err := secretGet()
133+
if err != nil || got != apiKey {
134+
return false
135+
}
136+
return true
137+
}
138+
99139
// IsAuthenticated reports whether the config holds valid credentials.
100140
func (c *Config) IsAuthenticated() bool {
101141
return c != nil && c.APIKey != ""
@@ -177,14 +217,19 @@ func (c *Config) Save() error {
177217
}
178218
c.SavedAt = time.Now().UTC()
179219

180-
// Decide where the secret lives.
220+
// Decide where the secret lives. persistSecret returns true ONLY when the
221+
// key landed in the keychain AND survives an immediate read-back; a silent
222+
// detached/headless write that doesn't round-trip returns false so we fall
223+
// back to the 0600 on-disk field (which Load() already reads). This guards
224+
// the "✓ Logged in but next whoami says Not logged in" headless regression.
181225
persisted := false
182226
c.FallbackAPIKey = ""
183227
if c.APIKey != "" {
184-
if err := secretstore.Set(c.APIKey); err == nil {
228+
if persistSecret(c.APIKey) {
185229
persisted = true
186230
} else {
187-
// Keychain unavailable — fall back to writing it on disk.
231+
// Keychain unavailable OR write didn't survive read-back — fall
232+
// back to writing the key on disk so the next invocation finds it.
188233
c.FallbackAPIKey = c.APIKey
189234
warnFileFallback()
190235
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
package cliconfig
2+
3+
// White-box tests for the headless/detached login token-persistence fix.
4+
//
5+
// Root cause being guarded: on a detached/headless box the OS keychain's
6+
// Available() probe is a false positive — Set() returns nil even though the
7+
// write lands in an ephemeral/locked session keyring the next process can't
8+
// read. Because Set didn't error, the old Save() never took the file-fallback
9+
// branch, so `instant login` printed "✓ Logged in" while persisting NOTHING
10+
// the next `instant whoami` could read.
11+
//
12+
// The fix verifies the keychain write with an immediate read-back and falls
13+
// back to the 0600 ~/.instant-config file when the value doesn't round-trip.
14+
// These tests inject the secretSet/secretGet seams to simulate that exact
15+
// silent-write failure WITHOUT touching the real OS keychain.
16+
17+
import (
18+
"errors"
19+
"os"
20+
"path/filepath"
21+
"testing"
22+
23+
"github.com/stretchr/testify/assert"
24+
"github.com/stretchr/testify/require"
25+
)
26+
27+
// withSecretSeams installs replacement secretSet/secretGet implementations for
28+
// the duration of one test and restores the originals on cleanup.
29+
func withSecretSeams(t *testing.T, set func(string) error, get func() (string, error)) {
30+
t.Helper()
31+
prevSet, prevGet := secretSet, secretGet
32+
secretSet, secretGet = set, get
33+
t.Cleanup(func() { secretSet, secretGet = prevSet, prevGet })
34+
}
35+
36+
// TestSave_SilentKeychainWriteFallsBackToFile is the core regression test for
37+
// the detached/headless login bug. The injected keychain ACCEPTS the write
38+
// (Set returns nil) but the read-back returns a DIFFERENT value (the
39+
// ephemeral/locked-session keyring the next process can't read). Save must
40+
// detect the non-durable write and persist the token to the 0600 file
41+
// fallback, and a fresh Load must return it — i.e. the next `instant whoami`
42+
// finds the token.
43+
func TestSave_SilentKeychainWriteFallsBackToFile(t *testing.T) {
44+
// Set "succeeds" but Get never returns what we wrote — the silent
45+
// detached-keychain failure mode.
46+
withSecretSeams(t,
47+
func(string) error { return nil }, // Set: false success
48+
func() (string, error) { return "", nil }, // Get: empty (write evaporated)
49+
)
50+
51+
dir := t.TempDir()
52+
t.Setenv("HOME", dir)
53+
path := filepath.Join(dir, ".instant-config")
54+
55+
cfg := &Config{path: path, APIKey: "inst_live_headless_secret", Email: "agent@example.com"}
56+
require.NoError(t, cfg.Save())
57+
58+
// The token MUST have landed in the on-disk fallback field.
59+
data, err := os.ReadFile(path)
60+
require.NoError(t, err)
61+
body := string(data)
62+
assert.Contains(t, body, FallbackAPIKeyField,
63+
"silent keychain write must trigger the file fallback")
64+
assert.Contains(t, body, "inst_live_headless_secret",
65+
"token must be persisted to disk when the keychain write doesn't round-trip")
66+
67+
info, err := os.Stat(path)
68+
require.NoError(t, err)
69+
assert.Equal(t, os.FileMode(0600), info.Mode().Perm(),
70+
"file fallback must stay mode 0600")
71+
72+
// And the next invocation (whoami / authed call) must read it back.
73+
// Load consults secretstore first; our seam still returns empty there, so
74+
// the value can only come from the on-disk fallback — exactly the path
75+
// `instant whoami` exercises after a headless login.
76+
loaded, err := Load()
77+
require.NoError(t, err)
78+
assert.Equal(t, "inst_live_headless_secret", loaded.APIKey,
79+
"a file-persisted token must be found on the next load (no false 'Not logged in')")
80+
assert.True(t, loaded.IsAuthenticated())
81+
assert.Equal(t, "file-fallback", loaded.SecretBackendName())
82+
}
83+
84+
// TestSave_KeychainSetErrorFallsBackToFile covers the OTHER headless mode:
85+
// Set itself errors (no DBus / locked collection / no active backend). The
86+
// file fallback must still capture the token.
87+
func TestSave_KeychainSetErrorFallsBackToFile(t *testing.T) {
88+
withSecretSeams(t,
89+
func(string) error { return errors.New("keychain set: dbus unavailable") },
90+
func() (string, error) { return "", errors.New("unreachable") },
91+
)
92+
93+
dir := t.TempDir()
94+
t.Setenv("HOME", dir)
95+
path := filepath.Join(dir, ".instant-config")
96+
97+
cfg := &Config{path: path, APIKey: "inst_live_set_errored", Email: "ci@example.com"}
98+
require.NoError(t, cfg.Save())
99+
100+
loaded, err := Load()
101+
require.NoError(t, err)
102+
assert.Equal(t, "inst_live_set_errored", loaded.APIKey)
103+
}
104+
105+
// TestSave_DurableKeychainWriteSkipsFileFallback is the happy-path guard: when
106+
// the keychain accepts the write AND it round-trips on read-back, the token
107+
// must NOT be written to disk (no plaintext leak — the T16 P1-1 property), and
108+
// persistSecret reports success.
109+
func TestSave_DurableKeychainWriteSkipsFileFallback(t *testing.T) {
110+
var stored string
111+
withSecretSeams(t,
112+
func(v string) error { stored = v; return nil },
113+
func() (string, error) { return stored, nil }, // faithful round-trip
114+
)
115+
116+
dir := t.TempDir()
117+
t.Setenv("HOME", dir)
118+
path := filepath.Join(dir, ".instant-config")
119+
120+
cfg := &Config{path: path, APIKey: "inst_live_durable", Email: "desktop@example.com"}
121+
require.NoError(t, cfg.Save())
122+
123+
data, err := os.ReadFile(path)
124+
require.NoError(t, err)
125+
body := string(data)
126+
assert.NotContains(t, body, FallbackAPIKeyField,
127+
"a durable keychain write must NOT write the file-fallback field")
128+
assert.NotContains(t, body, "inst_live_durable",
129+
"a durable keychain write must NOT leave the token in plaintext on disk")
130+
assert.Empty(t, cfg.FallbackAPIKey)
131+
}
132+
133+
// TestPersistSecret_TableDrivenOutcomes exercises persistSecret's three
134+
// outcomes directly so each branch (Set error / readback mismatch / readback
135+
// error / durable success) is unambiguously covered.
136+
func TestPersistSecret_TableDrivenOutcomes(t *testing.T) {
137+
cases := []struct {
138+
name string
139+
set func(string) error
140+
get func() (string, error)
141+
want bool
142+
}{
143+
{
144+
name: "set error -> not durable",
145+
set: func(string) error { return errors.New("boom") },
146+
get: func() (string, error) { return "tok", nil },
147+
want: false,
148+
},
149+
{
150+
name: "readback mismatch -> not durable",
151+
set: func(string) error { return nil },
152+
get: func() (string, error) { return "different", nil },
153+
want: false,
154+
},
155+
{
156+
name: "readback error -> not durable",
157+
set: func(string) error { return nil },
158+
get: func() (string, error) { return "", errors.New("locked") },
159+
want: false,
160+
},
161+
{
162+
name: "faithful round-trip -> durable",
163+
set: func(string) error { return nil },
164+
get: func() (string, error) { return "tok", nil },
165+
want: true,
166+
},
167+
}
168+
for _, tc := range cases {
169+
t.Run(tc.name, func(t *testing.T) {
170+
withSecretSeams(t, tc.set, tc.get)
171+
assert.Equal(t, tc.want, persistSecret("tok"))
172+
})
173+
}
174+
}

0 commit comments

Comments
 (0)