Skip to content

fix(cli): guard ssh/node prompts without a TTY, add secrets delete --yes, and test every prompt guard - #466

Merged
roodboi merged 1 commit into
nextfrom
wf/g76-prompt-site-guards
Oct 24, 2026
Merged

roodboi merged 1 commit into
nextfrom
wf/g76-prompt-site-guards

Conversation

@roodboi

@roodboi roodboi commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Follow-ups to #446 (HACK-1385) from its verifier.

Root cause

@clack/prompts opens a terminal readline over stdin. On a piped stdin it never settles and spins at full CPU. #446 put canPrompt() guards in front of the known prompt sites, but three gaps were left:

  1. The per-site guards had no regression test. Replacing the mcp guard with a no-op passed every existing test, while echo | hack mcp install spun.
  2. hack ssh and hack node pair walkthrough called clack with no guard. hack ssh --direct and hack ssh --host <h> were confirmed to spin on a piped stdin (killed at the bound with the prompt rendered). The node walkthrough gated its prompts on isTty(), which checks stdout only, so a terminal stdout with a piped stdin still reached text/confirm. Its "Execute remote approval now?" confirm had no gate at all.
  3. hack secrets delete <name> could not be scripted. Its fail policy refused every run that could not prompt, and no flag confirmed the deletion.

Fix

  • src/commands/ssh.ts, each site behind canPrompt():
    • Connection method: refuses with E_INTERACTIVE_REQUIRED, naming --tailscale and --direct --host <host>.
    • Direct host: refuses, naming --host.
    • Follow-up "What would you like to do?": answers "Done", because the connection info is already printed and the workspace can be passed as the positional argument.
    • "Turn on Tailscale?": declines with a note. It never runs tailscale up without an answer.
  • src/commands/node.ts:
    • The walkthrough input prompts gate on canPrompt() instead of isTty().
    • The execute-now confirm is skipped without a TTY. The session is left pending, using the existing "complete manually" path.
    • Other isTty() uses (passing interactive to the ssh bootstrap) are not clack prompts and are unchanged.
  • src/commands/secrets.ts: hack secrets delete <name> --yes confirms the deletion explicitly. Without --yes, a run that cannot prompt still refuses closed with E_INTERACTIVE_REQUIRED before the keychain is touched, and the hint now names --yes. A TTY run without --yes still asks, with default no.
  • src/commands/session.ts: promptAttachedWorkspaceAction is exported, with a doc comment, so its guard can be tested directly. In a non-TTY spawn the picker refuses first, so this guard is unreachable through the CLI.
  • Docs: docs/cli.md lists secrets delete --yes and ssh --host among the scriptable forms. docs/reference/cli.md is regenerated (bun run docs:cli-reference) for the new option and description. The consumer agent guidance does not mention these commands, so the plugin bundle is unchanged.

Tests

tests/no-tty-prompt-sites-cli.test.ts (new) has 14 cases. Each spawns a real child with privateCliEnv and a stdin that is piped and closed at once, and sets no HACK_NO_INTERACTIVE. The child also gets a private HACK_HOME, HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1 and HACK_SESSIONS_MUX=none, plus a SIGKILL bound of 10 s and a 30 s test timeout. Each case asserts no signal, wall time under the bound, no clack ◆ prompt rendered, and the policy outcome:

Site Command Expected
mcp resolveTargets mcp print E_INTERACTIVE_REQUIRED naming --all/--cursor/--claude
session picker session (one registered project) refusal naming hack session list / attach <name>
session attached workspace worker tests/fixtures/session-attached-prompt-worker.ts refusal naming --new
update confirmInstall update --json with a fetch-stub preload (release v999) and a stdout-TTY preload {"ok":false,"error":"Non-interactive shell; re-run with --yes …"}; the stand-in binary is untouched
secrets name secrets get refusal; the Bun.secrets stub log is empty
secrets value secrets set <name> refusal; stub log empty
secrets delete, no --yes secrets delete <name> refusal naming --yes; stub log empty
secrets delete --yes secrets delete <name> --yes (plus HACK_NO_INTERACTIVE=1) exit 0, Deleted secret, stub log delete hack-cli <name>
agent client picker agent init --path <repo> exit 0, prompt printed, no picker
ssh method ssh refusal naming --tailscale/--direct/--host
ssh host ssh --direct refusal naming --host
ssh follow-up ssh --host <h> exit 0, "Copy the SSH command above"
node walkthrough input node pair walkthrough with a stdout-TTY preload exit 1, Missing --source
node execute-now confirm node pair walkthrough --source … --endpoint <local Bun.serve> exit 0, "Session left pending"

New fixtures:

  • tests/fixtures/secrets-store-stub-preload.ts: a Bun.secrets stub, so no case reaches the OS keychain, even under mutation.
  • tests/fixtures/release-fetch-stub-preload.ts: answers only the GitHub latest-release URL. Any other fetch throws, so nothing is downloaded.
  • tests/fixtures/stdout-tty-preload.ts: a terminal stdout with a piped stdin. It proves the stdin half of canPrompt().

tests/global-command.macos.test.ts now counts calls that reach the mocked clack confirm. Two TTY-less cases (stdin and stdout isTTY=false, no opt-out) cover the two policies global commands use:

  • accept-default: the global trust keychain case is now test.each over --no-interactive and no-TTY. Both assert the sudo keychain install runs and confirm is never called.
  • decline: global authorize without a TTY skips the sudoers install, although the prompt's default is yes, and never calls confirm.
  • No global command uses the fail policy. fail without a TTY is covered by the secrets cases above and by the existing tests/interactivity.test.ts and tests/env-unset-no-tty-cli.test.ts.

Mutation proof

Each guard was mutated alone: the guard was made a no-op, or the original gate was restored where one existed. Then only its test was run. All 17 mutations were caught:

Mutation Result
mcp, session picker, session attached, secrets name/value, agent, ssh method/host/follow-up guards → if (false) each test fails: child SIGKILLed at 10 s
update isTty: canPrompt() → true fails: SIGKILL
update → process.stdout.isTTY === true (the pre-#446 gate) fails: SIGKILL
node input canPrompt() → isTty() (the old gate) fails: SIGKILL
node execute-now confirm guard removed fails: SIGKILL
secrets delete --yes ignored --yes test fails (exit 1, expected 0)
secrets delete --yes always on refusal test fails (exit 0, expected 1)
confirmSafe gate → !isExplicitlyNonInteractive() (pre-#446) both new global-command cases fail; the other 31 still pass

Evidence

  • Bun 1.4.2 (mise x bun@1.4.2), bun install --frozen-lockfile, no bun.lock change.
  • bun run --cwd packages/cli typecheck: 0 errors (covers tests/).
  • Root bun run check, which includes privacy:check: 2/2 tasks successful, privacy-check: ok.
  • ultracite check on every changed file: clean.
  • Focused run of 20 files: 181 pass, 0 fail (183 tests). The files are the new test, cli-spawn-env-isolation, global-command.macos, env-unset-no-tty-cli, interactivity, ssh-command, the session-* files, node-pair-command, node-workspace-command, node-attach-instructions, cli-per-command-options, cli-reference-docs, update-command, mcp-install, mcp, agent-onboard-command and doctor-fix-noninteractive.
  • gitleaks 8.30.1 over the exported origin/next..HEAD range: no leaks.
  • Before the fix, echo | hack ssh --direct and echo | hack ssh --host example.test were killed at a 6 s / 30 s bound with the clack prompt rendered. After the fix, both settle in about 1–3 s (CLI cold start) with the policy outcome above.
  • Full suite: hosted CI test job at the exact PR head 3a3efd24 passed: 8109 pass, 0 fail (8266 tests across 575 files, including tests/no-tty-prompt-sites-cli.test.ts; run 40057772445). The docker-e2e shards 1–4, the ubuntu Candidate core and Config compiler jobs, runtime models, linux-process-lifetime, runtime-images and secret-scan also passed. The macOS Candidate core and Config compiler jobs were still queued for a runner when this was written.

Gaps

  • The session attached-workspace guard is tested through a worker that imports the exported function. In a spawned non-TTY CLI the picker guard always fires first, so this guard is defense in depth.
  • node.ts still passes interactive: isTty() to the ssh key bootstrap (ensureNodePairSshReady). That path runs ssh with an inherited stdin, not a clack prompt, so it is out of scope here. ssh/node are unsupported surfaces (AGENTS.md); only their clack prompt sites were guarded.
  • Each CLI spawn takes about 2.5–3 s here (cold module graph), so the new file takes about 40 s. The 10 s kill bound leaves headroom for slower hosted runners.
  • No fail-policy confirmSafe site exists in global commands, so global-command.macos.test.ts covers decline and accept-default only.

Refs HACK-1410

🤖 Generated with Claude Code

…yes, and test every prompt guard

Follow-ups to #446 (HACK-1385):

- Add one bounded non-TTY spawn test per canPrompt() guard (mcp target
  selection, session picker and attached-workspace choice, update install
  confirmation, secrets name/value prompts, agent client picker), each
  proven by mutation.
- Guard the clack prompts in `hack ssh` (connection method, host, follow-up
  choice, Tailscale start) and `hack node pair walkthrough` (input prompts
  now gate on canPrompt() instead of stdout alone; the execute-now confirm
  leaves the session pending without a TTY).
- `hack secrets delete <name> --yes` confirms the deletion explicitly; without
  it a TTY-less run still refuses closed with E_INTERACTIVE_REQUIRED, and the
  hint now names --yes.
- tests/global-command.macos.test.ts keeps a TTY-less case for the decline
  and accept-default policies and asserts the prompt is never entered.

Refs HACK-1410

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Oct 24, 2026

Copy link
Copy Markdown

HACK-1410

@roodboi
roodboi marked this pull request as ready for review October 24, 2026 05:48
@roodboi
roodboi merged commit f678e8f into next Oct 24, 2026
17 checks passed
@roodboi
roodboi deleted the wf/g76-prompt-site-guards branch October 24, 2026 05:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant