Setup credential stage: scope the keychain timeout to keychain operations, not the whole interactive flow¶
- Authors
- Matt Cockayne, Claude Fable 5 (AI drafting assistant)
- Date
- 2026-07-23
- Status
- IMPLEMENTED (2026-07-23). GTB: Initialiser ctx plumbing + per-operation
keychain scoping with red-first regression tests (pkg/setup/forge/
ctx_scoping_test.go). Providers: forge-github + forge-gitlab v0.2.1 bound
device.Wait by the code's ExpiresIn; GTB pins bumped. Open questions were
resolved by the maintainer:
Q1 — full context plumbing through the
setup.Initialiserseam (breakingConfigure,RunGitHubInit,RunBitbucketInitsignatures; migration note atdocs/reference/migration/v0.x-initialiser-context.md), with regression tests validating the defect before the fix. Q2 — same change: forge-github and forge-gitlab internally bounddevice.Waitby the device code'sExpiresIn(fallback 15 min for zero/negative values), released in lockstep and picked up by GTB. - Related
- architectural review (CRITICAL finding),
forge-aware setup: auth + SSH (the migration that introduced the flow),
credentials module extraction (origin of
KeychainOpTimeout)
1. Problem¶
credentials.KeychainOpTimeout (5 s, go/credentials/wizard.go:16) is documented as a
bound on "any single credentials-backend operation". The forge-aware setup wizard
instead uses it as a deadline for the entire interactive credential stage:
pkg/setup/forge/single.go:147-148createsctx, cancel := context.WithTimeout(context.Background(), credentials.KeychainOpTimeout)for the already-configuredResolveTokenContextcheck — then reuses that same ctx for everything that follows: it is passed intorunAuthCredentialStage(single.go:172, 178-209), from there intocaptureToken→auth.Login(ctx, i.prompter)(single.go:274), and intowriteSingleCredential→credentials.Store(ctx, …)(single.go:339 via 313).pkg/setup/forge/dual.go:81-93has the identical shape: one 5 s ctx created at dual.go:81-82 spans the whole form flow and thewriteDualCredentialscall at dual.go:93.
The 5-second clock starts before the storage-mode selector, the env-var-name form,
the fetch-token confirmation, the device-code prompt, and the OAuth poll loop. The
GitHub and GitLab providers honour the deadline in device.Wait(ctx, …)
(go/forge-github/auth.go:57, go/forge-gitlab/auth.go:54).
Failure scenario. No human completes a browser device flow in under 5 seconds, so
the forge-driven OAuth login — the headline capability of the forge-aware-setup
migration — always expires its deadline. captureToken then logs a warning and
falls back to manual PAT entry (single.go:279-282), which masks the bug: setup
"works", but the OAuth path is dead in production while unit tests (which stub the
authenticator) pass. Additionally, any context-honouring credentials backend (e.g. a
future Vault/SSM backend) fails Store for the same reason; the built-in OS-keychain
backend survives only because its implementation ignores ctx.
The codebase already contains the correct pattern:
singleStorageModeForm/dualStorageModeForm (dual.go:105-112) and
bitbucket.SettingsFromConfig (go/forge-bitbucket/config_adapter.go:13-14) each
derive a fresh KeychainOpTimeout ctx immediately around the single keychain call.
2. Proposed change¶
Re-scope contexts so each deadline covers only the operation it was designed for:
configureAuth(single.go) — keep aKeychainOpTimeout-derived ctx for theResolveTokenContextalready-configured check, but cancel it there; do not pass it onward.runAuthCredentialStagereceives the caller's ctx (see point 4).captureToken/Login— run the OAuth device flow under the caller's ctx. The device flow already carries its own expiry (DeviceCode.ExpiresIn, surfaced by the provider); optionally derivecontext.WithTimeout(ctx, expiresIn)inside the providers, but the setup layer must not impose a shorter artificial bound.writeSingleCredential/writeDualCredentials→credentials.Store— derive a freshcontext.WithTimeout(ctx, credentials.KeychainOpTimeout)at the call site of each keychain operation, matching the documented contract and the existingdualStorageModeFormpattern.- Thread a real caller ctx.
configureAuth/configureDualcurrently start fromcontext.Background(). Plumb the command's ctx (from the cobra command via the initialiser entry point) through so Ctrl-C cancels a pending device-flow poll. If plumbing the command ctx through thesetup.Initialiserseam is too invasive for this fix, an acceptable interim iscontext.Background()at the stage entry with the keychain-scoped deadlines applied per-operation — but note it in the code.
Alternative considered: raising KeychainOpTimeout to cover a device flow
(e.g. 15 min). Rejected — it would gut the timeout's purpose (a locked keychain
stalling first-run setup) and still couples two unrelated concerns to one constant.
3. Scope & release plan¶
- go-tool-base only:
pkg/setup/forge/single.go,pkg/setup/forge/dual.go(+ tests). Nogo/forge*orgo/credentialsAPI change is required —KeychainOpTimeout's documented contract is already correct; the consumer violates it. - Ships as
fix(setup): …— a patch release via the releaser-pleaser Release MR. - If point 4 (caller ctx) changes the exported initialiser signatures in
pkg/setup/forge, that is a permitted pre-1.0 break; note it in the commit body and add a migration note indocs/migration/.
4. Acceptance criteria¶
- The ctx passed to
auth.Logincarries noKeychainOpTimeout-derived deadline; a regression test drivescaptureTokenwith a fakeAuthenticatorwhoseLoginblocks ≥ 6 s (fake clock or short injected constant) and asserts it completes without falling back to manual entry. - Every
credentials.Store/Probe/Retrievecall inpkg/setup/forgederives its ownKeychainOpTimeoutctx at the call site; a test with a fake backend asserts the observed deadline is ~5 s from the store call, not from stage entry. dual.goconfigureDualgets the same treatment assingle.go; the shared shape is covered by tests for both.- A test asserts the fallback-to-manual path still engages on a genuine
Loginerror (e.g.forge.ErrNotSupported), so the graceful degradation contract is preserved. - Manual verification on a real terminal:
gtb initagainst GitHub completes a full device flow taking > 5 s and stores the token without the "falling back" warning. just cigreen; coverage on touchedpkg/setup/forgefiles stays ≥ 90%.
5. Open questions¶
- Should the command ctx be plumbed through the initialiser seam now (breaking the
pkg/setup/forgeentry-point signatures), or is the per-operation scoping withcontext.Background()at stage entry acceptable for this fix, deferring ctx plumbing to a follow-up? - Should providers internally bound
device.Waitby the device code'sExpiresIn(defence against a caller passing an unbounded ctx), and if so, should that land in the same change or as a forge-github/forge-gitlab follow-up?