TTY-guard the interactive prompts in the root pre-run (telemetry consent, update prompt)¶
- Authors
- Matt Cockayne, Claude Fable 5 (AI drafting assistant)
- Date
- 2026-07-23
- Status
- DRAFT — pending review
- Related
- architectural review (GTB-core §HIGH
pre-run prompt TTY-guard finding). Context: the resolved MR !157 init-hang incident —
huh forms hung the CLI e2e suite on non-terminal stdin, and the fix established the
utils.IsInteractive()guard as the codebase convention for every interactive flow.
1. Problem¶
Two interactive huh prompts run inside the root PersistentPreRunE with no TTY
guard:
promptTelemetryConsent(pkg/cmd/root/root.go:1019-1076) — a confirm form shown whenTelemetryCmdis enabled buttelemetry.enabledis unset;- the update confirm in
handleOutdatedVersion(root.go:546-599,form.Run()atroot.go:571-579) — shown when the tool is behind and the update policy is prompt/enabled.
Both rely on the documented assumption that "without a usable TTY … form.Run returns an
error" (root.go:565-568). That is exactly the assumption the MR !157 incident
disproved: huh forms hung the e2e suite on non-terminal stdin, and the resolution
established utils.IsInteractive() as the convention — now used at
pkg/setup/init.go:170, pkg/setup/update.go:314 and update.go:354,
pkg/cmd/config/edit.go:62, and internal/cmd/feature_toggle.go:72. The two pre-run
prompts are the remaining unguarded sites, and they run before every command.
Concrete failure scenarios:
- MCP stdio.
tool mcp startis spawned by an MCP client with stdin as the open JSON-RPC pipe. On a machine where telemetry is feature-enabled but unconfigured (gtb itself ships in exactly this state), the consent form runs against the pipe: it can block indefinitely or consume protocol bytes from stdin, and its rendering writes to the stdout that carries the JSON-RPC responses. The same applies to the update prompt for tools withUpdatePolicyPromptonce they fall behind. - Piped/scripted stdin. Any
tool <cmd> < fileor cron invocation on an unconfigured-telemetry machine attempts the prompt on every run. - CI is only half-detected. The consent skip checks the
ciconfig/flag value (view.GetBool("ci"),root.go:1041) but not theCIenvironment variable that the forge initialisers already honour (pkg/setup/forge/profile.go:192,os.Getenv("CI") == "true"). A real CI run that does not pass--cire-attempts the consent prompt on every invocation (and, because non-interactiveform.Runerrors are swallowed at debug level, does so silently and never persists a choice).
2. Proposed change¶
- Gate both prompts on
utils.IsInteractive(). promptTelemetryConsent: return early (debug log, no persistence) when!utils.IsInteractive()— the unset state remains unset and the prompt reappears on the next interactive run, which is the intended one-time-opt-in behaviour.handleOutdatedVersion: skipform.Run()entirely when non-interactive, leavingrunUpdate = false. Policy semantics are unchanged:promptcontinues with the existing warning;enabledstill yields the hard "update required" error (root.go:590-596) — but now deterministically, without touching stdin.- Honour
CI=truealongside--ci. Add anos.Getenv("CI") == "true"check to the consent skip (matchingpkg/setup/forge/profile.go:192) and toshouldSkipUpdateCheck's CI gate (root.go:506-513) so config-flag and environment detection agree. Factor a single small helper (e.g.isCIEnvironment(view)) used by both, rather than a third ad-hoc check. - Never prompt under the
mcpcommand. Add the mcp command to the pre-run's prompt exemptions (aligned with the bootstrap fast-path work in the companion auxiliary-command-exemptions spec, viasetup.FeatureOf(cmd) == p.McpCmd— not a name match). Even with an interactive terminal, an MCP server process must not write prompt UI to its protocol stdout. - Guard placement note.
utils.IsInteractivechecks stdin; the MCP case shows stdout matters too. KeepIsInteractiveas the shared gate but confirm (and test) that it covers the piped-stdout case, or gate mcp explicitly per (3) — which this spec requires regardless.
3. Scope & release plan¶
- GTB-repo only:
pkg/cmd/root(both prompt sites, CI helper, mcp exemption). No publicpkg/API changes expected; ships asfix(root)(patch). - Unit tests: table tests driving
promptTelemetryConsentandhandleOutdatedVersionwith an injected interactivity check (the existingWithFormseam atroot.go:540-544covers form substitution; add an equivalent injectable gate rather than a package-level variable, per the no-package-level-mocking-hooks rule). - E2E/BDD implications: yes. The MR !157 incident was caught by the e2e suite; add
Gherkin coverage for the surviving prompts: "Given telemetry is enabled but
unconfigured, When I run
<tool> <cmd>with piped stdin, Then the command completes without prompting and no consent is persisted", and anmcp start-under-pipe scenario asserting clean JSON-RPC output. Gate under the existing CLI/smoke tags; run with-count=1. /gtb-verifybefore the MR; update the root pre-run docs indocs/components/.
4. Acceptance criteria¶
- With stdin a pipe (not a TTY): any command on an unconfigured-telemetry tool completes
without blocking, writes no prompt bytes to stdout/stderr, reads no bytes from stdin,
and persists no
telemetry.enabledvalue. - With
CI=truein the environment and no--ciflag: the consent prompt and the pre-run update check are both skipped (parity withview.GetBool("ci")). tool mcp startnever renders a prompt, interactive terminal or not; an e2e scenario exchanges at least one JSON-RPC message over stdio on a telemetry-unconfigured, update-behind configuration without corruption.- With
UpdatePolicyEnabledand non-interactive stdin, the pre-run returns the existing "update required" error immediately (noform.Runattempt); withUpdatePolicyPromptit warns and continues. - Interactive behaviour is unchanged: on a real TTY both prompts appear exactly as today (existing form tests still pass).
5. Open questions¶
- Should
CI=truealso suppress the persistentwarnIfBehindCachedreminder (currently gated only onview.GetBool("ci"),root.go:455-457), for full parity between the flag and the environment variable? - Should the non-interactive consent skip write nothing forever (current proposal), or
persist an explicit
telemetry.enabled: falseafter N silent skips to stop re-evaluating? (Proposal: never auto-persist — absence of consent is not refusal.) - Does
utils.IsInteractiveneed extending to consider stdout (for prompt rendering) as well as stdin, codebase-wide, or is the mcp-feature exemption sufficient for the only stdout-sensitive case?