Defer keyring probe to first credential use; belt the installers - #578
Conversation
Sensitive Change Detection (shadow mode)This PR modifies control-plane files:
|
There was a problem hiding this comment.
All reported issues were addressed across 6 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
The pwsh belt test proved restore-to-unset only; a regression that overwrites an existing caller value would have passed. Second driver pass sets the var to 0 before Invoke-PostInstallSetup and asserts it survives. Review feedback on #578.
Every CLI command runs root PersistentPreRunE -> appctx.NewApp -> auth.NewManager -> auth.NewStore, and credstore.NewStore eagerly probes OS keyring availability with a write. On macOS that probe shells out to an uncancellable `security` child; a locked keychain with no TTY or GUI blocks it forever — on *every* command, including credential-free ones like `setup agents` and `skill install`. That is how the installer hung headless (#568, canary discovery #569/#571 -> #574 drill lineage). Go fix (reaches users at the next release): Store now records the fallback dir at construction and builds the credstore lazily behind sync.Once on first credential operation. Credential-free commands never touch the keyring; credential-touching commands probe on first use exactly as before, with the documented BASECAMP_NO_KEYRING=1 escape hatch. No timeout was added deliberately: an abandoned goroutine cannot kill the hung `security` child, so a timeout would hide the hang while keeping the leak. Bounded fallback needs cancellation support upstream in credstore/go-keyring first. Installer belt (deploys on merge, covers released binaries <= v0.7.2 which still probe eagerly): post_install_setup in install.sh prefixes each best-effort child with per-command BASECAMP_NO_KEYRING=1; Invoke-PostInstallSetup in install.ps1 sets it for the duration of setup and restores the caller's value on exit. These children never touch credentials — the var only skips the old binaries' startup probe. Tests: keyring_test.go pins constructor laziness through an injectable credstore seam. installer.bats gains belt tests that explicitly unset the suite-wide BASECAMP_NO_KEYRING and assert both directions (real calls carry it, the sh capability probe does not), plus a pwsh test that extracts Invoke-PostInstallSetup from install.ps1's AST (no test hooks in the production script), runs it against a logging stub, and asserts set + restore; it fails closed when pwsh is missing in CI. The canary's workflow-level BASECAMP_NO_KEYRING is removed with no step-scoped replacement: the macOS leg now genuinely exercises the deployed belt (a missing belt = hung security child = timeout = notify), and the direct `basecamp version` asserts skip PersistentPreRunE entirely. Refs #558 #568 #569
The pwsh belt test proved restore-to-unset only; a regression that overwrites an existing caller value would have passed. Second driver pass sets the var to 0 before Invoke-PostInstallSetup and asserts it survives. Review feedback on #578.
d539249 to
76e9b97
Compare
There was a problem hiding this comment.
Pull request overview
Defers OS keyring probing to first credential use (avoiding hangs on headless macOS for credential-free commands) and “belts” the installers so post-install setup of older binaries won’t trigger the eager startup keyring probe.
Changes:
- Lazily constructs the underlying
credstore.Storebehindsync.Onceon first credential operation. - Adds installer “belt” behavior:
BASECAMP_NO_KEYRING=1is applied to best-effort post-installsetup/skillcalls (scoped per-command ininstall.sh, scoped to setup duration with restoration ininstall.ps1). - Updates installer smoke canary to remove workflow-level
BASECAMP_NO_KEYRINGso CI exercises the deployed belt.
Tip
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| scripts/install.sh | Prefixes post-install setup/skill invocations with BASECAMP_NO_KEYRING=1 to prevent hangs for older eager-probe binaries. |
| scripts/install.ps1 | Sets BASECAMP_NO_KEYRING=1 for the duration of Invoke-PostInstallSetup and restores the prior value (or absence) on exit. |
| internal/auth/keyring.go | Makes credstore.NewStore lazy via sync.Once so credential-free paths don’t hit the keyring probe. |
| internal/auth/keyring_test.go | Adds a constructor seam + test to assert lazy construction and one-time initialization behavior. |
| e2e/installer.bats | Adds belt assertions for both installers; validates install.ps1 env-var restore behavior via AST-extracted function evaluation. |
| .github/workflows/installer-smoke.yml | Removes workflow-level BASECAMP_NO_KEYRING to ensure the canary exercises the deployed installer belt. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Stacked on #575 (retargets to main when it merges). Draft until #575 lands.
The hang is CLI-wide, not a CI quirk
Every command runs root
PersistentPreRunE→appctx.NewApp→auth.NewManager→auth.NewStore→credstore.NewStore, which eagerly probes OS keyring availability with a write. On macOS that probe shells out to an uncancellablesecuritychild (go-keyringkeyring_darwin.go); a locked keychain with no TTY or GUI blocks forever — on every command, including credential-free ones likesetup agentsandskill install. That's how the headless installer hung (#568; canary discovery #569).The fix (two layers)
Go — lazy construction (
internal/auth/keyring.go):NewStorerecords the fallback dir; the credstore is built behindsync.Onceon first credential operation. Credential-free commands (setup agents,skill install, completion, etc.) never touch the keyring. Credential-touching commands probe on first use exactly as before, with the documentedBASECAMP_NO_KEYRING=1escape hatch. Reaches users at the next release.Installer belt (deploys on merge, covers released binaries ≤ v0.7.2 which still probe eagerly):
post_install_setupin install.sh prefixes each best-effort child with per-commandBASECAMP_NO_KEYRING=1;Invoke-PostInstallSetupin install.ps1 sets it for the duration of setup and restores the caller's value on exit. These children never touch credentials — the var only skips old binaries' startup probe. Thesetup --helpcapability probes in install.sh stay bare (help short-circuitsPersistentPreRunE).Deliberately no probe timeout: an abandoned goroutine cannot kill the hung
securitychild, so a timeout would hide the hang while preserving the resource leak. Bounded fallback for credential-touching commands is deferred pending cancellation support upstream in credstore/go-keyring (issue to follow on basecamp/cli).Canary re-scope
The workflow-level
BASECAMP_NO_KEYRINGis removed with no step-scoped replacement: the macOS leg now genuinely exercises the deployed belt (a missing belt = hungsecuritychild = 10-minute timeout = notify), and the directbasecamp versionasserts skipPersistentPreRunEentirely (root.go handles help/version before app construction). Once a release with the lazy fix ships, the canary stops distinguishing belt presence; the bats + pwsh tests remain the durable belt verification.Tests
keyring_test.go: counting-fake seam proves the constructor makes zero credstore calls, first operation makes one, subsequent operations reuse it. (The "credential-free commands never probe" conclusion additionally rests on the audited command paths — stated in the test comment, not overclaimed.)installer.bats: belt tests explicitly unset the suite-wideBASECAMP_NO_KEYRINGand assert both directions — realsetup agents/skill installcalls lognk=1, the shsetup --helpprobe logsnk=unset(belt is scoped, not blanket).Invoke-PostInstallSetupfrom install.ps1's AST viaParser.ParseFileand evaluates only that function — no test hooks or skip-main env vars in the production script — then asserts real calls see1and the env var is restored (removed) after return. Skips locally without pwsh; fails closed in CI.Verification
go test ./internal/auth/...and fullbin/cigreen;bats e2e/installer.bats14 pass + pwsh skip locally.restored-ok,nk=1 setup agents.completion bashaverages 105ms on the eager binary vs 43ms lazy — the ~60ms delta is the keyring probe, gone.auth statusoutput is byte-identical eager vs lazy with the keyring active, and unchanged underBASECAMP_NO_KEYRING=1.curl … install-cli?sha=<merge-sha> | bashin a scratch HOME without exportingBASECAMP_NO_KEYRING.Refs #558 #568 #569
Summary by cubic
Defers the OS keyring probe to first credential use and adds a scoped installer “belt” that sets
BASECAMP_NO_KEYRING=1to prevent hangs on headless macOS and in older releases (≤ v0.7.2). Also updates CI to exercise the belt directly.Bug Fixes
internal/auth/keyring.gonow buildscredstore.Storeon first use viasync.Oncewith a test seamnewCredStore. Credential-free commands never touch the keyring; credentialed paths are unchanged and still honorBASECAMP_NO_KEYRING.scripts/install.shprefixes realsetup/skillcalls withBASECAMP_NO_KEYRING=1while keepingsetup --helpbare;scripts/install.ps1setsBASECAMP_NO_KEYRING=1for the duration ofInvoke-PostInstallSetupand restores any caller value on exit..github/workflows/installer-smoke.ymlremoves the workflow-level var so macOS canaries validate the deployed belt.Tests
internal/auth/keyring_test.goto pin lazy construction. Expandede2e/installer.batsto assert the sh belt and a PowerShell pass that verifies both restoring to unset and preserving a caller-setBASECAMP_NO_KEYRINGvalue.Written for commit 76e9b97. Summary will update on new commits.