Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ require (
charm.land/bubbletea/v2 v2.0.8
charm.land/lipgloss/v2 v2.0.5
github.com/basecamp/basecamp-sdk/go v0.9.1-0.20260727173625-bb363c847b92
github.com/basecamp/cli v0.2.1
github.com/basecamp/cli v0.2.2-0.20260728023309-04e401b12c6c
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/glamour v1.0.0
github.com/charmbracelet/huh v1.0.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/basecamp/basecamp-sdk/go v0.9.1-0.20260727173625-bb363c847b92 h1:4gQJTR8e5kBvXHDHLvef+Q00XY7KzWTxnQSLWMSPcyA=
github.com/basecamp/basecamp-sdk/go v0.9.1-0.20260727173625-bb363c847b92/go.mod h1:r83ralDQ0q9vbAby5qQ5x9hgCgUdJLDLHYpiU6jaFjE=
github.com/basecamp/cli v0.2.1 h1:8GyehPVtsTXla0oOPu4QgXRjwwzJ99prlByvyi+0HRQ=
github.com/basecamp/cli v0.2.1/go.mod h1:p8tt/DatJ2LAzWO6N6tNfV8x3gF5T3IxDTo+U8FfWPo=
github.com/basecamp/cli v0.2.2-0.20260728023309-04e401b12c6c h1:+5sQBl8sqYoD1Qhwsibn8sBCKWPyZ9NDez6mnuo9Afo=
github.com/basecamp/cli v0.2.2-0.20260728023309-04e401b12c6c/go.mod h1:EK1Dba6DEw8ZAilVBpf/jri3ONDV7LQkLACSDe73f/c=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
Expand Down
34 changes: 32 additions & 2 deletions internal/auth/keyring.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import (
"fmt"
"os"
"sync"
"time"

"github.com/basecamp/cli/credstore"
"github.com/charmbracelet/x/term"
)

// Credentials holds OAuth tokens and metadata.
Expand Down Expand Up @@ -38,6 +40,30 @@ type Store struct {
// newCredStore is replaceable in tests to avoid real keyring access.
var newCredStore = credstore.NewStore

// sessionIsHeadless reports that no human can answer a keyring unlock
// prompt: stdin, stdout, and stderr are all non-terminals AND no GUI
// session is available. Any attached stream counts as interactive —
// redirecting a stream or two (`2>auth.log`, `| jq`) is routine interactive
// use — and a GUI session (an app or IDE task runner launching the CLI with
// all streams detached) can still present the unlock dialog. Replaceable in
// tests.
var sessionIsHeadless = func() bool {
return !term.IsTerminal(os.Stdin.Fd()) &&
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
!term.IsTerminal(os.Stdout.Fd()) &&
!term.IsTerminal(os.Stderr.Fd()) &&
!guiSessionAvailable()
}

// headlessProbeTimeout bounds the keyring availability probe when no
// terminal is attached to any standard stream. A headless session (CI,
// piped installers, ssh without a TTY) can never answer a keychain unlock
// prompt, so an unavailable keyring must fall back to file storage instead
// of hanging forever in an uncancellable `security` child — the #568
// incident class. Interactive sessions keep the unbounded probe: a locked
// keychain there raises an unlock prompt, and cutting it off mid-answer
// would silently degrade the user to plaintext file storage.
const headlessProbeTimeout = 10 * time.Second

// NewStore creates a credential store. The OS keyring is not touched until
// the first credential operation.
func NewStore(fallbackDir string) *Store {
Expand All @@ -49,11 +75,15 @@ func NewStore(fallbackDir string) *Store {
// sync.Once provides the synchronization.
func (s *Store) ensure() *credstore.Store {
s.initOnce.Do(func() {
s.inner = newCredStore(credstore.StoreOptions{
opts := credstore.StoreOptions{
ServiceName: "basecamp",
DisableEnvVar: "BASECAMP_NO_KEYRING",
FallbackDir: s.fallbackDir,
})
}
if sessionIsHeadless() {
opts.ProbeTimeout = headlessProbeTimeout
}
s.inner = newCredStore(opts)
})
return s.inner
}
Expand Down
34 changes: 34 additions & 0 deletions internal/auth/keyring_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,37 @@ func TestNewStoreIsLazy(t *testing.T) {
store.UsingKeyring()
assert.Equal(t, 1, calls, "construction happens once")
}

// swapSessionIsHeadless replaces the interactivity seam for the test.
func swapSessionIsHeadless(t *testing.T, headless bool) {
t.Helper()
orig := sessionIsHeadless
sessionIsHeadless = func() bool { return headless }
t.Cleanup(func() { sessionIsHeadless = orig })
}

// ensureOptions constructs a store's credstore through the seams and returns
// the options it was built with.
func ensureOptions(t *testing.T, headless bool) credstore.StoreOptions {
t.Helper()
t.Setenv("BASECAMP_NO_KEYRING", "1") // keep the delegated construction off the real keyring

var got credstore.StoreOptions
swapNewCredStore(t, func(opts credstore.StoreOptions) *credstore.Store {
got = opts
return credstore.NewStore(opts)
})
swapSessionIsHeadless(t, headless)

NewStore(t.TempDir()).UsingKeyring()
return got
}

// Headless sessions can never answer a keychain unlock prompt, so the probe
// must be bounded there — the #568 incident class. Interactive sessions keep
// the unbounded probe so a legitimate unlock prompt is never cut off
// mid-answer (which would silently degrade to plaintext file storage).
func TestEnsureBoundsProbeOnlyWhenHeadless(t *testing.T) {
assert.Equal(t, headlessProbeTimeout, ensureOptions(t, true).ProbeTimeout)
assert.Zero(t, ensureOptions(t, false).ProbeTimeout)
}
23 changes: 23 additions & 0 deletions internal/auth/session_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package auth

import (
"context"
"os/exec"
"strings"
"time"
)

// guiSessionAvailable reports whether the process runs inside a macOS Aqua
// (GUI) session, where the keychain unlock prompt appears as a WindowServer
// dialog even with every standard stream detached — GUI-launched apps and
// IDE task runners. `launchctl managername` prints "Aqua" there; ssh and CI
// run under StandardIO or Background. launchctl only inspects the launchd
// session — it cannot touch the keychain — but is bounded anyway, and any
// failure counts as no GUI so genuinely headless sessions keep the bounded
// probe.
func guiSessionAvailable() bool {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "/bin/launchctl", "managername").Output()
return err == nil && strings.TrimSpace(string(out)) == "Aqua"
}
11 changes: 11 additions & 0 deletions internal/auth/session_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//go:build !darwin && !windows

package auth

import "os"

// guiSessionAvailable reports a display server the keyring's unlock prompt
// could use even with every standard stream detached.
func guiSessionAvailable() bool {
return os.Getenv("DISPLAY") != "" || os.Getenv("WAYLAND_DISPLAY") != ""
}
22 changes: 22 additions & 0 deletions internal/auth/session_other_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//go:build !darwin && !windows

package auth

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestGUISessionAvailableTracksDisplayEnv(t *testing.T) {
t.Setenv("DISPLAY", "")
t.Setenv("WAYLAND_DISPLAY", "")
assert.False(t, guiSessionAvailable())

t.Setenv("DISPLAY", ":0")
Comment thread
jeremy marked this conversation as resolved.
assert.True(t, guiSessionAvailable())

t.Setenv("DISPLAY", "")
t.Setenv("WAYLAND_DISPLAY", "wayland-0")
assert.True(t, guiSessionAvailable(), "Wayland-only sessions have no DISPLAY")
}
9 changes: 9 additions & 0 deletions internal/auth/session_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package auth

// guiSessionAvailable is always false on Windows: Credential Manager
// operates without prompting, so a bounded probe cannot cut off an
// interaction, and misclassifying a GUI-launched session as headless is
// harmless here.
func guiSessionAvailable() bool {
return false
}