Skip to content

fix(cli): resolve prompts by policy when stdin or stdout is not a TTY - #446

Merged
roodboi merged 1 commit into
nextfrom
wf/g64-prompt-no-tty
Oct 24, 2026
Merged

roodboi merged 1 commit into
nextfrom
wf/g64-prompt-no-tty

Conversation

@roodboi

@roodboi roodboi commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Root cause

hack env unset KEY with stdin/stdout not a TTY and without HACK_NO_INTERACTIVE=1 never returned and burned a full CPU core (measured 99% CPU, 3.1 s CPU in 3 s wall, killed only by an external timeout).

confirmSafe in src/lib/interactivity.ts only consulted the explicit opt-out (--no-interactive / HACK_NO_INTERACTIVE). A TTY-less run without that opt-out fell into the "legacy clack behavior" branch and entered @clack/prompts confirm, whose Prompt.prompt() opens a readline interface with terminal: true over stdin and waits for keypress events. On a piped stdin that loop never settles and re-renders continuously.

canPrompt() (TTY on both streams plus the opt-outs) already existed and the raw text/password/select sites in env.ts were guarded with it; the shared confirm helper was the one path that was not.

Fix

  • confirmSafe gates on canPrompt(). Whenever prompting is disallowed (no TTY on stdin or stdout, HACK_NO_INTERACTIVE, --no-interactive) the declared policy resolves the answer within milliseconds: accept-default applies the documented default, decline answers no, fail throws E_INTERACTIVE_REQUIRED with the scriptable hint. The clack prompt is never entered without a TTY (documented as an invariant on the helper).
  • doctorConfirm prints its "skipped destructive step" note on the same condition (!canPrompt()), not only under the explicit opt-out.
  • Audit of every @clack/prompts call site (confirm/select/text/password/multiselect/autocompleteMultiselect and doctorConfirm); sites with the same latent loop are routed through the convention:
    • secrets: name prompt, value prompt and delete confirmation (now confirmSafe with fail) refuse closed without a TTY, before the keychain is touched.
    • session: the default picker and the attached-workspace choice refuse with E_INTERACTIVE_REQUIRED naming hack session list / hack session attach <name> / --new.
    • mcp: target selection refuses, naming --all / --cursor / --claude / --codex.
    • update: the install confirmation checks canPrompt() (stdin as well as stdout; the previous process.stdout.isTTY check let a piped stdin spin).
    • agent: the client picker uses canPrompt() (already short-circuited on raw isTTY; now also honors the opt-outs).
    • Already guarded and left as they are: env.ts raw prompts, project.ts init prompts (behind the io.canPrompt() seam), setup.ts, init-agent-integrations.ts (interactive flag), native-domain-dns-authorization.ts (uses confirmSafe).
  • docs/cli.md (agent/scripted ergonomics): documents that a run whose stdin or stdout is not a TTY resolves every prompt by its non-interactive policy, with env unset and doctor --fix as examples of safe defaults and update --yes / mcp --all / session attach <name> as examples of closed refusals.

No CLI options or help text changed, so docs/reference/cli.md and the agent plugin bundle are unchanged.

Tests

  • tests/interactivity.test.ts: three new cases stub stdin.isTTY=false (stdout true, no opt-out) and assert accept-default returns the default within 1 s, decline returns false, and fail throws E_INTERACTIVE_REQUIRED mentioning "no TTY" and the hint. Each is bounded by a 2 s test timeout.
  • tests/env-unset-no-tty-cli.test.ts (new): spawns the real CLI with piped stdin, no HACK_NO_INTERACTIVE, private HOME/HACK_HOME/DOCKER_CONFIG, the env layer file written first, HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1, and a 5 s SIGKILL bound. Asserts env unset exits 0, removes the key, prints no prompt and never prints the value (canary), with wall time < 5 s and CPU time < 3 s (proc.resourceUsage()); and that secrets delete <name> (a fail-policy site) exits 1 with E_INTERACTIVE_REQUIRED naming the way out, before any keychain access.
  • Mutation proof: restoring the old gate (!isExplicitlyNonInteractive()) makes all five new tests fail (the three unit cases time out at 2 s; both e2e cases are SIGKILLed at ~5.0 s with the key still present). Re-running tests/global-command.macos.test.ts under that mutation passes 31/31, which proves the 12 failures it showed under the fix came from the fix, not a pre-existing flake.
  • tests/global-command.macos.test.ts: its clack confirm mock stands in for an interactive user, and bun test has no TTY, so the file now stubs both streams' isTTY to true per test (restored in afterEach, reusing its restoreIsTty helper). The one test that exercises the non-interactive sudo -n fallback pins stdin back to non-TTY.

Evidence

  • Bun 1.4.2 (mise x bun@1.4.2), bun install --frozen-lockfile, no bun.lock change.
  • Local (this Mac, worktree at f0c217a1): bun run --cwd packages/cli typecheck 0 errors; root bun run check (includes privacy:check) 2/2 tasks successful, 0 errors (34 pre-existing warnings); focused run of 17 test files covering every touched command and every file that mocks clack: 188 pass, 0 fail (191 tests, 3 skipped) after the test-fixture adjustments above.
  • gitleaks 8.30.1 over the exported origin/next..HEAD range: no leaks.
  • Reproduction before/after: echo "" | bun index.ts env unset DOOMED in a temp project: before, killed at 5 s with the prompt rendered and the key still present; after, exit 0 in 0.21 s wall / 0.27 s CPU with the key removed.
  • Full suite on the M3 at this exact commit: pending, result appended below.

Gaps

  • src/commands/ssh.ts and src/commands/node.ts still call clack select/text/confirm without a canPrompt() guard. Both are unsupported remote/node surfaces per AGENTS.md, so they were left untouched; reported as findings.
  • The non-TTY behavior for env unset is the documented default (the key is removed, matching HACK_NO_INTERACTIVE=1), not a refusal; there is no --yes/--force on env unset and none was added.
  • The e2e CPU bound uses Bun.Subprocess.resourceUsage() and is skipped if the runtime returns undefined; the wall-time and signal assertions still fail a regressed prompt path.

Refs HACK-1385

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

`hack env unset KEY` with a piped stdin and no HACK_NO_INTERACTIVE never
returned and burned a full CPU: `confirmSafe` only consulted the explicit
opt-out, so a TTY-less run entered the clack confirm, whose readline loop
cannot settle on a non-TTY stdin (HACK-1385).

- `confirmSafe` now gates on `canPrompt()` (TTY on both streams plus the
  opt-outs) and resolves every TTY-less run by its declared policy within
  milliseconds; `doctorConfirm` notes skipped destructive steps on the same
  condition.
- Raw clack prompts without a guard are routed through the convention:
  `secrets` (name, value, delete), the `session` picker and attached-workspace
  choice, `mcp` target selection, `update` (stdin as well as stdout), and the
  `agent` client picker.
- Regression tests: unit cases for each policy without a TTY, and an e2e
  spawn of `env unset` and `secrets delete` with piped stdin under a 5 s kill
  bound asserting exit, bounded CPU and wall time, and no value echo.
- docs/cli.md documents the non-TTY behavior.

Refs HACK-1385

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

linear-code Bot commented Oct 23, 2026

Copy link
Copy Markdown

HACK-1385

@roodboi
roodboi marked this pull request as ready for review October 24, 2026 02:42
@roodboi
roodboi merged commit 9ff1c0b into next Oct 24, 2026
17 checks passed
@roodboi
roodboi deleted the wf/g64-prompt-no-tty branch October 24, 2026 02:42
roodboi added a commit that referenced this pull request Oct 24, 2026
Follow-ups to #433 (HACK-1375) and #435 (HACK-1376), both already on `next`.

## Root cause

1. **The unset compare-and-swap was not tested.** `unsetProjectEnvValue` passes
`expectedExisting: text` to the layer write. If you delete that line, every lock, hydrate and
config test still passes, because only the set path had a paused-writer conflict test. 2. **Some
layer locks can never be reclaimed, and the message did not say so.** `withEnvLayerLock` reuses
the projects-registry owner protocol. That protocol never removes a malformed receipt, a receipt
it does not trust (another uid, or writable by group or others), a receipt whose PID now belongs
to a live process, or a dead owner behind a leftover `<lock>.recovery` guard. In each of these
cases every `env set/add/unset/hydrate` on the layer waited 10 s and then printed `Another hack
env command is writing <file>; … Retry when it finishes.` No retry could ever succeed, and the
message did not name the lock file. 3. **A key can become unignored after a later `git init`.**
In a non-git directory, the first `--secret` creates the key and writes its root `.gitignore`
entry. Outside git, an existing key deliberately triggers no `.gitignore` write. So if
`.gitignore` is deleted and the directory then gets `git init`, the key is not ignored, and Hack
is not running at that moment. 4. **The re-include refusal named the wrong cause.** The ignore
probe that runs after the entry is in place produced the same "Hack could not add it to
<.gitignore>" message as a failed write. That is wrong when `.gitignore` already contains
`.hack.secret.key` followed by `!.hack.secret.key`.

## Fix

- `src/lib/env-layer-lock.ts`: `EnvLayerLockTimeoutError` now has a `reason` (`held` |
  `recovery_guard`) and the `lockPath`. The message is still one value-free line with no stack:
  - `held`: `Another hack env command is writing <file>; gave up after waiting 10s for its lock
  at <lock>. Nothing was written. Retry when it finishes. If no hack env command is running,
  Hack cannot reclaim that lock itself: remove <lock> and retry.`
  - `recovery_guard`: when the registry reports a dead owner behind a busy guard, the message
  keeps the registry's `recovery guard is busy (interrupted recovery requires offline
  inspection)` detail and names both `<lock>.recovery` and `<lock>` as the files to remove.
  - The lock helper still never removes anything itself. `src/lib/projects-registry-lock.ts` is
  unchanged; the classification reads the registry's existing timeout detail. That file belongs
  to G73 / HACK-1377.
- `src/lib/project-env-config.ts`: `projectEnvKeyUnignoredError` now takes a `cause`.
  `unwritable` keeps the existing message. `reincluded` says the root `.gitignore` lists the key
  but a later rule in that file re-includes it, and asks the user to remove that rule. Once the
  entry is present, no deeper ignore file can apply to a key at the project root, so a later
  rule in that same file is the only possible cause.
- `src/commands/doctor.ts`: exports `checkProjectEnvKey` so the real doctor check can be tested
  end to end. It also moves a misplaced docblock onto that function. The check's behavior is
  unchanged.
- `docs/env.md`: documents unreclaimable locks and the recovery step, the re-include cause, and
  the `git init` decision. It also adds an `env-key-unignored` example to the doctor table.

### Decision on a later `git init` (item 3)

Watching for `git init` is out of scope for env writes, because Hack runs no code when it
happens. Outside git, an existing key still needs no entry, and Hack does not recreate a root
`.gitignore` the user deleted. That behavior is unchanged and covered by the existing test. The
guarantee comes back at the next Hack touchpoint:

- `hack doctor`'s env key check reports `env-key-unignored` (warn) with a remedy. This check
  already existed; the new test proves it catches this exact sequence.
- The first `--secret` set or hydrate in the new repository adds the entry before it uses the
  key, or it refuses (#433 behavior).

## Tests (each proven by mutation)

| Test | Mutation that makes it fail | | --- | --- | | `project-env-layer-lock`: *an unset
refuses with a conflict when a writer that takes no lock changed the layer after its read*. A
`spyOn` pauses `resolvePrimaryLocalProjectDir`, the one awaited call between the local unset's
read and its write. The test edits the layer, resumes, and expects `AtomicWriteConflictError`
with the edit kept. | delete `expectedExisting: text,` from the unset: 1 fail | | `project-env-
layer-lock`: *a layer lock Hack cannot reclaim…* uses three receipts: malformed, group-writable
with an exited PID, and PID 1 standing in for a reused PID. Set and unset each refuse with the
exact message, the receipt is left untouched, and removing the named lock lets the retry
succeed. The existing held-lock test asserts the new message too. | restore the old message: 2
fail | | `project-env-layer-lock`: *a dead owner behind an interrupted recovery guard…* covers a
set (300 ms wait) and a hydrate (default 10 s): both give the `recovery_guard` message with both
paths, write nothing, and leave the lock and guard in place. After both are removed, the set
succeeds. | classify the guard case as `held`: 1 fail | | `project-env-key-ignore`: the re-
include test now expects the `reincluded` message, does not contain "could not add it", and
checks that `.gitignore` was not rewritten. | send `unwritable` for the post-entry probe: 1 fail
| | `project-env-key-ignore`: *a key whose entry was removed before a later git init…* runs the
exact reported sequence through the source CLI, then the real `checkProjectEnvKey`: status
`warn`, `env-key-unignored`, and no key bytes or absolute root in the output. The next
`--secret` set repairs the entry and doctor returns `ok`. | drop `inspectLocalKeyIgnored` from
doctor: 1 fail |

Every fixture uses a private HOME, HACK_HOME, global config and Docker config,
`HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1`, and git without global or system config. The env
layer is written before any `--secret`. The in-process doctor call swaps in the same private
environment and restores it afterwards.

## Evidence

- Manual repro before the fix: (2) dead owner plus guard, and a malformed receipt, gave the old
  "Retry when it finishes." message. (3) after `git init`, `git check-ignore` exits 1 and `git
  status` lists `.hack.secret.key`. (4) gave the "could not add it" message. After the fix, the
  same script prints the messages above, and removing the named lock makes the retry exit 0.
- Focused: `tests/project-env-layer-lock.test.ts` 12/12 and `tests/project-env-key-
  ignore.test.ts` 8/8 pass locally.
- `bun run check` (includes `privacy:check`) exit 0. `bun run --cwd packages/cli typecheck` exit
  0. gitleaks 8.30.1 (`gitleaks dir` on the `git log -p origin/next..HEAD` export): no leaks.
- The CLI surface and consumer agent guidance did not change, so no reference or plugin
  regeneration was needed.
- Full suite: hosted CI `test` at this PR head (see checks).

## Gaps

- In a wider local run of 11 related env/doctor files, 13 tests in `project-env-write-refusals`,
  `project-env-stable-ciphertext` and `project-env-worktree-key` failed with
  `beforeEach/afterEach hook timed out` while the host load average was 51–60 on 18 cores. None
  of them asserts a message this PR changes. Hosted CI is the evidence for them.
- The refusal is still a `ClosedCliError` with no `E_*` code (as before). Adding a lock-busy
  code would change the error-code registry and its generated docs, so it is left for a separate
  decision.
- Classifying `recovery_guard` depends on the registry's existing timeout text. If G73 rewords
  that text, the guard test fails in the merge train rather than silently falling back to
  `held`.
- The `held` message cannot tell a live writer from an unreclaimable lock without parsing the
  receipt (code G73 is editing), so it covers both cases: retry, or remove the lock when no hack
  env command is running.
- The unset CAS test uses `spyOn` on an ESM namespace export (`worktree-local-config.ts`), in
  process and restored in `finally`.

## Round 2: merge with current next

While this PR was being verified, `next` moved from 7d9c89f to 5208ee5 (#404, #446, #430,
#439, #421, #448, #452, #405), and the PR became `CONFLICTING`.

- Merge commit 7ac7cbb merges `origin/next` (5208ee5) into the branch. The only conflict was
  the `checkProjectEnvKey` docblock in `src/commands/doctor.ts`, which #448 also moved. It is
  resolved to this PR's side: the extended docblock plus `export async function
  checkProjectEnvKey`. Compared with next, `doctor.ts` now differs only in those lines.
- No other code changed in this round. The diff against next is still the same 6 files.
- Gates on the merged head: `bun run check` (includes `privacy:check`) exit 0. `bun run --cwd
  packages/cli typecheck` exit 0. Focused tests `project-env-layer-lock`, `project-env-key-
  ignore` and `cli-spawn-env-isolation` pass 27/27 (`--timeout 120000`) under #405's new
  preloads. gitleaks 8.30.1 (`gitleaks dir` on the `git log -p origin/next..HEAD` export): no
  leaks.
- Full suite: hosted CI run 40056707145 at 7ac7cbb (base 5208ee5): `test` passed in 38m45s,
  plus docker-e2e 1-4/4, Candidate core (ubuntu), Config compiler (ubuntu x64/arm,
  macos-15-intel), Runtime state models, linux-process-lifetime, runtime-images and secret-scan.
  The two `macos-latest` jobs were still queued for a runner after about an hour. The earlier
  green run 39978726682 was at ed877f5, before the base moved, so it does not count for this
  head.

Refs HACK-1386

🤖 Generated with [Claude Code](https://claude.com/claude-code)
roodboi added a commit that referenced this pull request Oct 24, 2026
…yes, and test every prompt guard (#466)

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](https://claude.com/claude-code)
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