Skip to content

fix(install): read prompt from /dev/tty when stdin is piped#42

Merged
sanity merged 2 commits into
mainfrom
fix-install-tty
Apr 30, 2026
Merged

fix(install): read prompt from /dev/tty when stdin is piped#42
sanity merged 2 commits into
mainfrom
fix-install-tty

Conversation

@sanity
Copy link
Copy Markdown
Contributor

@sanity sanity commented Apr 30, 2026

Problem

The Linux install script advertised on the quickstart page is invoked via
curl -fsSL https://freenet.org/install.sh | sh. When piped, stdin is the
curl output (script bytes), not the user's terminal, so read -r response
at the service-install prompt silently consumes nothing and the prompt
ignores keyboard input.

Reported by Ivvor on Matrix:

@ian: Freenet linux install script is broken. Streaming curl to sh causes
it to not process keyboard input when asking if you want the service
installed.

Approach

A previous fix (eff317ae, Dec 2025) wrote read -r response < /dev/tty,
but PRs #36 / #37 ("resync install.sh + uninstall.sh with freenet-core")
overwrote the file with the freenet-core copy and dropped the override.

This change reapplies the fix and hardens it:

  • stdin is a TTYread directly (covers sh install.sh).
  • stdin is piped, /dev/tty openableread < /dev/tty (covers curl | sh).
  • no tty at all → skip the prompt with an info message rather than
    aborting under set -eu (covers CI / setsid).

{ true </dev/tty; } 2>/dev/null is used instead of [ -r /dev/tty ],
because the -r access check can succeed when the process has no
controlling tty and the subsequent open fails with ENXIO.

Testing

Smoke-tested with a small harness that extracts the prompt block:

  • Case A — curl | sh under a real pty (script -c): stdin is piped,
    /dev/tty present. Script reads y from /dev/tty and proceeds with
    service install. ✅
  • Case B — setsid ... </dev/null (no controlling tty): script prints
    "Non-interactive shell detected; skipping service installation prompt"
    and continues without erroring. ✅
  • sh -n parses cleanly.

Follow-up

freenet-core/scripts/install.sh carries the identical bug (the two files
are bit-identical). A matching PR will land there next so the next resync
doesn't regress this file again.

[AI-assisted - Claude]

sanity added 2 commits April 30, 2026 10:30
When the install script is run via the documented `curl ... | sh`
pattern, stdin is the pipe carrying script bytes, so `read -r response`
cannot capture keystrokes and the service-install prompt silently
ignores keyboard input (reported by Ivvor on Matrix).

A previous fix (eff317ae, Dec 2025) addressed this with `< /dev/tty`,
but PRs #36 / #37 resynced this file from freenet-core and dropped the
override. Reapply the fix and harden it for environments without a
controlling terminal.

The new logic:
- If stdin is a TTY, read normally (covers `sh install.sh`).
- Else, probe /dev/tty by actually opening it (`{ true </dev/tty; }`)
  rather than relying on `[ -r /dev/tty ]`, which can return true even
  when the open later fails. If /dev/tty is openable, read from it
  (covers `curl | sh`).
- Otherwise, skip the prompt rather than aborting under `set -eu`
  (covers truly non-interactive shells, e.g. CI or `setsid`).

Verified via a pty harness: `curl | sh` simulation under `script(1)`
reads "y" from /dev/tty and proceeds; `setsid ... </dev/null` skips
the prompt and prints follow-up instructions.

Note: freenet-core/scripts/install.sh has the identical bug; a matching
fix will follow there to keep the resync in sync.

[AI-assisted - Claude]
Five-perspective review on PR #42 surfaced three issues with the
initial fix:

- Codex P2: discriminating only on `[ -t 0 ]` regresses
  `printf 'y' | sh install.sh` automation patterns. When stdin is
  piped but the script ran from a file, the user's answer is on
  stdin, not /dev/tty.

- Skeptical #1: `read -r response` returning EOF (Ctrl-D, closed
  stdin) under `set -eu` aborts the script.

- Skeptical #10: comment used an em-dash, which Ian's writing-style
  rule rejects.

Redesigned the dispatch around `$0`: when sh runs a script file
(file-execution), $0 is the script path; when sh reads its own
source from stdin (`curl | sh`), $0 is the shell name. The case
arm tests for known shell names and only redirects to /dev/tty in
that branch. The file-execution arm reads from stdin as before, so
piped automation still works.

Both `read` calls now have `|| response=""` so EOF leaves the
default-N case path intact rather than aborting.

Also (lower priority feedback applied):

- Skipped path now mentions FREENET_NO_SERVICE=1 as the documented
  way to bypass the prompt non-interactively (Skeptical #2/#3).

- Removed the redundant "Non-interactive shell detected" info line
  (Skeptical #7); the existing `*)` case arm already prints
  "Skipping service installation" plus remediation guidance.

- Added a NOTE block at the top of the file pointing future
  resync agents at the freenet-core sibling and explaining why
  the prompt block must not be flattened back to a plain `read`
  (Big-picture #5).

Re-tested via the same pty harness across five scenarios:
  1. `curl | sh` under pty: reads 'y' from /dev/tty, installs.
  2. `sh install.sh` interactive: reads from stdin.
  3. `printf 'y' | sh install.sh`: reads from piped stdin (regression
     test for Codex's concern).
  4. `setsid ... </dev/null`: skips, prints FREENET_NO_SERVICE hint.
  5. `sh install.sh </dev/null`: handles EOF without aborting.

All five pass.

[AI-assisted - Claude]
@sanity
Copy link
Copy Markdown
Contributor Author

sanity commented Apr 30, 2026

Review-feedback response

Ran 4 internal reviewers (code-first, testing, skeptical, big-picture) and Codex CLI against the initial commit. Summary of how each finding was handled in the follow-up commit de6e42c7:

Addressed

# Source Finding Resolution
1 Codex P2 [ -t 0 ]-only check makes printf 'y' | sh install.sh automation block on /dev/tty. Switched to case "${0##*/}" discriminator: if $0 is a shell name (sh/bash/etc.), the script source came from stdin (curl | sh), so read from /dev/tty; otherwise stdin carries the answer, read from stdin. Verified printf 'y' | sh install.sh still works.
2 Skeptical #1 read EOF (Ctrl-D, closed stdin) aborts under set -eu. Both read calls now || response="".
3 Skeptical #10 Em-dash in comment violates writing-style rule. Removed; comment block rewritten without em-dashes.
4 Skeptical #2/#3 Silent skip in non-interactive contexts gives no remediation hint. *) case arm now appends "To skip this prompt entirely in scripted installs, set FREENET_NO_SERVICE=1."
5 Skeptical #7 Redundant "Non-interactive shell detected" + "Skipping service installation" messages. Removed the info line; the *) case arm already prints the skip message.
6 Big-picture #5 No deterrent to a future resync silently dropping the fix again. Added a NOTE block at the top of install.sh cross-referencing freenet-core/scripts/install.sh and naming this PR + the prior regression.

Deferred (worth doing, separate PR scope)

# Source Finding Why deferred
- Testing #1 CI grep-guard against the regressed two-line form. Highest-leverage testing item, but it's CI infrastructure — better as its own PR alongside the freenet-core fix.
- Testing #2/#3 Extract prompt block into a function + unit test, plus a setsid sh -c 'cat install.sh | sh' end-to-end smoke test. Refactor + new test infrastructure; out of scope for a hotfix.
- Skeptical #4 Behavior on busybox ash (Alpine). Would need a CI matrix that doesn't exist; relying on POSIX-defined { ...; } 2>/dev/null semantics for now. If anyone reports issues on Alpine we fix them then.

Manual verification

Re-ran the pty harness over five scenarios (all pass):

  1. curl | sh under pty: reads y from /dev/tty, installs service.
  2. sh install.sh interactive (heredoc): reads y from stdin.
  3. printf 'y' | sh install.sh (Codex's case): reads y from piped stdin.
  4. setsid sh -c './install.sh' </dev/null: skips prompt, prints FREENET_NO_SERVICE=1 hint.
  5. sh install.sh </dev/null: handles EOF without aborting under set -eu.

Companion PR against freenet-core/scripts/install.sh is next.

[AI-assisted - Claude]

@sanity sanity merged commit 9a23f46 into main Apr 30, 2026
2 checks passed
@sanity sanity deleted the fix-install-tty branch April 30, 2026 15:37
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