fix(cli): add requireTTY() before each unguarded interactive TUI launch (#982)#1640
fix(cli): add requireTTY() before each unguarded interactive TUI launch (#982)#1640aidandaly24 wants to merge 4 commits into
Conversation
) Non-TTY interactive TUI commands crashed with Ink's 'Raw mode is not supported on the current process.stdin' and, for import (no --source), exited 0 so CI/scripts could not detect the failure. deploy was already guarded by requireTTY (PR aws#949); import/view/export were not. Add requireTTY() before each unguarded interactive launch: - export/index.ts: before renderTUI for export-harness - import/command.ts: in the no-source branch before render(ImportFlow) - view/command.tsx: at the top of launchTuiList and launchTuiDetail requireProject()/--json paths untouched. Adds unit tests covering the three command suites. Refs aws#982
Package TarballHow to installgh release download pr-1640-tarball --repo aws/agentcore-cli --pattern "*.tgz" --dir /tmp/pr-tarball
npm install -g /tmp/pr-tarball/aws-agentcore-0.21.0.tgz |
agentcore-cli-automation
left a comment
There was a problem hiding this comment.
Thanks for tackling this — the fix targets the correct three unguarded sites (import no-source, view list/detail, export harness) and the symptom is real. A few things to address before merge:
-
PR title fails
validate-pr-titleCI check — the subject must start with a lowercase letter. The current titlefix(cli): Add requireTTY() ...needs to becomefix(cli): add requireTTY() .... This is currently a failing required check on the PR. -
Excessive mocking in all three new test files — each test mocks
requireTTYitself (the very thing this PR adds), so the assertions effectively just check that the new line of source code exists beforerender/renderTUI. That's a tautology against the source; the tests would still pass ifrequireTTYwere called afterrender, sincemockRequireTTYonly throws andrenderis also mocked. See inline comments for the suggested approach (drive the realrequireTTYby settingprocess.stdin.isTTY/process.stdout.isTTYand spying onprocess.exit+console.error, mirroringsrc/cli/commands/feedback/__tests__/consent-prompt.test.ts). -
Consider the centralized fix path the PR description recommends — the description itself notes that a more robust approach is to call
requireTTY()at the top ofrenderTUI()insrc/cli/tui/render.ts:35, which would coverexport(and any future TUI launch via that entrypoint) by default, leaving only the two rawrender()sites (import,view) needing inline guards. As written, this PR fixes the three known sites but the next interactive command added viarenderTUIwill hit the same bug. Worth either doing now or filing a follow-up.
Minor/non-blocking: the ordering of requireTTY vs requireProject is inconsistent between import (TTY first) and view (project first). Probably fine, but pick one for consistency.
| vi.mock('../../../tui/guards', () => ({ | ||
| requireTTY: () => mockRequireTTY(), | ||
| })); | ||
|
|
There was a problem hiding this comment.
Mocking requireTTY itself means this test isn't actually verifying the guard's behavior — it only verifies that something called mockRequireTTY runs before something that calls mockRenderTUI. If a future refactor moved requireTTY() after renderTUI(), this test would still pass because mockRequireTTY only throws and mockRenderTUI is a no-op that's then never reached anyway via the rejected promise.
Suggest exercising the real requireTTY:
const origStdinIsTTY = process.stdin.isTTY;
const origStdoutIsTTY = process.stdout.isTTY;
// Per test, set them to false and spy on process.exit / console.error.Then mock only renderTUI (true I/O boundary) and assert that with non-TTY, process.exit(1) was called and renderTUI was not; with TTY, renderTUI was called. This is the pattern used in src/cli/commands/feedback/__tests__/consent-prompt.test.ts.
| const mockRequireProject = vi.fn(); | ||
| const mockRender = vi.fn(); | ||
|
|
||
| vi.mock('../../../tui/guards/tty', () => ({ |
There was a problem hiding this comment.
Same excessive-mocking concern as the export test: requireTTY itself is mocked, so the test is a tautology against the source line ordering. Please drive the real guard by toggling process.stdin.isTTY / process.stdout.isTTY and spying on process.exit/console.error, and only mock the I/O boundaries (ink.render, react, and the ImportFlow import).
With the current mock setup the test also wouldn't catch a regression where requireProject() is moved before requireTTY() — both are mocked to never throw, so the test happy-path passes even if the guard never runs in a non-TTY context.
|
|
||
| vi.mock('../../../tui/guards', () => ({ | ||
| requireProject: () => mockRequireProject(), | ||
| requireTTY: () => mockRequireTTY(), |
There was a problem hiding this comment.
Same excessive-mocking concern. Two additional notes specific to view:
launchTuiListbranches ontype(recommendation / batch-evaluation / ab-test / insights). Onlyrecommendationis exercised here. The guard is at the top of the function so it's fine in practice, but a single parameterized test over all four types would prevent a future refactor from accidentally moving the guard into a per-type branch.- In production
requireProject()runs at line 57 (action handler) beforelaunchTuiList/launchTuiDetail, so non-TTY + no-project users will see the no-project error rather than the TTY error. That's probably the right ordering, but it's the opposite ofimport/command.ts:28where the PR putsrequireTTYbeforerequireProject. Worth picking one ordering and applying consistently.
| if (!cliOptions.source) { | ||
| // No --source and no subcommand — launch interactive TUI | ||
| const { requireTTY } = await import('../../tui/guards/tty'); | ||
| requireTTY(); |
There was a problem hiding this comment.
Two small things on this site:
- Inconsistent ordering:
requireTTYruns beforerequireProjecthere, but inview/command.tsxrequireProjectruns first (at the action-handler level, line 57). Either is defensible — pick one. PuttingrequireProjectfirst arguably gives a more actionable error to users in CI who just haven'tcd'd to the right place. - Style consistency: most call sites do
import { requireTTY } from '../../tui/guards'(the barrel) — onlyrun/command.tsxand nowimport/command.tsuse the deep import'../../tui/guards/tty'. Not a blocker, just inconsistent.
| console.log(JSON.stringify({ success: false, error: '--name is required in non-interactive mode' })); | ||
| process.exit(1); | ||
| } | ||
| requireTTY(); |
There was a problem hiding this comment.
Consider moving this guard into renderTUI() itself (at the top of src/cli/tui/render.ts:35) rather than at each call site. Your own PR description identifies this as the more robust approach: it would cover this site automatically and protect any future renderTUI() caller by default. The raw render() sites in import and view would still need inline guards, but those are the minority. As written, the next interactive command added via renderTUI() (e.g., another export subcommand) will silently regress to the same bug.
- centralize the interactive-terminal guard at the top of renderTUI() so every TUI entrypoint (export-harness and any future renderTUI caller) is covered by default; keep inline guards only on the raw render() sites (import, view) - rewrite the three tty-guard tests to exercise the real requireTTY by toggling process.stdin.isTTY/process.stdout.isTTY and spying on process.exit + console.error, mocking only the I/O boundaries (mirrors feedback/consent-prompt.test.ts) instead of mocking requireTTY - parameterize the view list guard over all four job types - make requireTTY-vs-requireProject ordering consistent: requireProject() first in import (matches view)
|
Claude Security Review: no high-confidence findings. (run) |
agentcore-cli-automation
left a comment
There was a problem hiding this comment.
Re-reviewed after commit 5edc308 (test(cli): drive real requireTTY guard; centralize in renderTUI). All serious issues from the prior CHANGES_REQUESTED review have been addressed:
- PR title — now lowercase (
fix(cli): add requireTTY()...). - Excessive mocking — the three new test files now drive the real
requireTTYby togglingprocess.stdin.isTTY/process.stdout.isTTYand spying onprocess.exit+console.error. Only true I/O boundaries (ink.render, screen modules, telemetry, notices) are mocked. This is the pattern the prior review asked for. - Centralized guard —
requireTTY()is now called at the top ofrenderTUI()(src/cli/tui/render.ts:39), soexportand any future TUI launched throughrenderTUIis covered by default; only the two rawrender()sites (import,view) need inline guards. - Ordering consistency —
import/command.tsnow runsrequireProject()beforerequireTTY(), matchingview, with a comment explaining why. viewparameterization — the newviewtest parameterizes over all four job types (recommendation,batch-evaluation,ab-test,insights).- Barrel import —
import/command.tsnow imports from'../../tui/guards'rather than the deepttypath.
No new blocking issues found. The redundancy of requireTTY() being called both at existing call sites (e.g. cli.ts:158, deploy/command.tsx:222,225, etc.) and now inside renderTUI() is harmless — the guard is idempotent and the duplicate calls can be cleaned up in a follow-up if desired.
LGTM.
|
Claude Security Review: no high-confidence findings. (run) |
|
LGTM. Reviewed after commit 5edc308. All serious issues raised in the prior automation review have been addressed:
The duplication between the new No new blocking issues found. |
launchTuiDevScreenWithPicker called render() without a requireTTY() guard, so in a non-TTY context it threw Ink's "Raw mode is not supported" stack trace plus a partial alt-screen paint — a counterexample to the centralized guard PR aws#1640 added. Call requireTTY() at the top of the picker (before the alt-screen write and render) and add a tty-guard test that drives the real guard for this path.
agentcore-cli-automation
left a comment
There was a problem hiding this comment.
LGTM. The prior automation reviews already flagged the substantive issues (excessive mocking, centralized guard, view parameterization, requireProject/requireTTY ordering, barrel import) and the current revision addresses all of them. I verified:
requireTTYis now at the top ofrenderTUI()insrc/cli/tui/render.ts:37, soexport harnessand any futurerenderTUIcaller are covered by default.- The three raw
render()call sites that bypassrenderTUIare guarded inline:import/command.ts:31,view/command.tsx:97(launchTuiList) and:118(launchTuiDetail). launchTuiDevScreenWithPickerindev/browser-mode.ts:332— the browser-mode picker — is also guarded. This site is not mentioned in the PR description but is a real previously-unguarded path (dev/command.tsx:473'srequireTTYonly covers the--no-browserbranch), so the addition is correct.- All four new test files drive the real
requireTTYby togglingprocess.stdin.isTTY/process.stdout.isTTYand spying onprocess.exit/console.error, mocking only true I/O boundaries (Ink renderer, screen modules). Each test also assertsmockRenderis not called on the non-TTY paths and is called on the TTY path, so a regression that moved the guard afterrender()would actually be caught. - View tests are parameterized over all four job types (
recommendation,batch-evaluation,ab-test,insights), preventing a future per-type refactor from moving the guard. import/command.tsrunsrequireProject()beforerequireTTY(), matchingview, with a comment explaining the rationale.- Telemetry: not applicable here. The guard short-circuits with
process.exit(1)before command logic; existingrunCliCommand/withCommandRunTelemetrywrappers continue to cover the underlying commands and no new metric semantics are introduced.
No new blocking issues.

Fixes #982
Problem
Running an interactive TUI command in a non-TTY context (piped stdin, CI, backgrounded) crashes with Ink's "Raw mode is not supported on the current process.stdin" stack trace. For
agentcore import(no--source) the process then exits 0, so CI/scripts can't detect the failure;agentcore view <type>,agentcore export harness, and theagentcore devbrowser-mode harness picker hit the same unguarded launch.Fix
Guard every interactive launch with
requireTTY()(prints a clear "requires an interactive terminal" message and exits non-zero):renderTUI()so anyrenderTUI-based launch (export, and future commands) is covered by default.render()sites:importandview.devbrowser-mode picker (launchTuiDevScreenWithPicker).requireProject/requireTTYordering made consistent across sites.Validation
All four interactive paths (import, view, export, dev picker) now exit 1 with the plain-text error and no Ink stack trace in a non-TTY context; the TTY path is unchanged. Tests drive the real
requireTTY(togglingprocess.stdin/stdout.isTTY, spying onprocess.exit/console.error), verified non-tautological by mutation (remove the guard → test fails). Unit suite green.