Skip to content

fix: only allocate TTY when both stdin and stdout are terminals#441

Open
AaronFeledy wants to merge 5 commits intomainfrom
test/tty-escape-codes-345
Open

fix: only allocate TTY when both stdin and stdout are terminals#441
AaronFeledy wants to merge 5 commits intomainfrom
test/tty-escape-codes-345

Conversation

@AaronFeledy
Copy link
Member

@AaronFeledy AaronFeledy commented Feb 28, 2026

Summary

ANSI escape codes leak into redirected output because Lando allocates a TTY inside Docker containers based solely on process.stdin.isTTY, ignoring whether stdout has been redirected.

When a user runs lando composer show > file.txt from a terminal, stdin is still a TTY but stdout is a file. Lando tells Docker to allocate a TTY anyway, so the container process thinks it has a full terminal and emits ANSI escape codes — which end up in the file.

Root Cause

Two code paths, same bug:

lib/compose.js:29 — docker-compose exec:

// Before:
exec: {detach: false, noTTY: !process.stdin.isTTY}
// After:
exec: {detach: false, noTTY: !(process.stdin.isTTY && process.stdout.isTTY)}

utils/build-docker-exec.js:11 — direct docker exec:

// Before:
if (process.stdin.isTTY) exec.push('--tty');
// After:
if (process.stdin.isTTY && process.stdout.isTTY) exec.push('--tty');

Fix

Only allocate a TTY when both stdin and stdout are terminals. This preserves colors for interactive use while producing clean output when redirected to files or piped to other commands.

Why unit tests instead of Leia tests

This bug only manifests when process.stdin.isTTY is true but process.stdout.isTTY is false — i.e., running from a real terminal with stdout redirected to a file. GitHub Actions runners don't allocate a real PTY, so process.stdin.isTTY is always false in CI. That means Leia tests can never reproduce the buggy condition — they'd pass regardless of whether the fix is applied. Unit tests let us mock the TTY state directly and validate the logic under all four stdin/stdout combinations.

Tests

6 unit tests in test/tty-allocation.spec.js covering all TTY state combinations:

  • ✅ stdin=TTY, stdout=TTY → allocate TTY (interactive terminal)
  • ✅ stdin=TTY, stdout=redirected → no TTY (the bug fix)
  • ✅ stdin=not TTY, stdout=TTY → no TTY (non-interactive)
  • ✅ stdin=not TTY, stdout=not TTY → no TTY (CI/scripts)

All 6 pass with the fix applied. The two that validate the bug (stdin=TTY, stdout=redirected) fail without it.

Related


Note

Low Risk
Low risk, localized change to TTY flag calculation for docker exec/docker compose exec, with added unit tests; primary risk is minor behavioral change for edge cases where only one stream is a TTY.

Overview
Prevents ANSI escape codes from leaking into redirected/piped output by changing TTY allocation to require both process.stdin.isTTY and process.stdout.isTTY.

This updates both the docker compose exec path (lib/compose.js sets noTTY accordingly) and the direct docker exec path (utils/build-docker-exec.js only adds --tty when both streams are TTYs), adds focused unit coverage in test/tty-allocation.spec.js, and records the fix in CHANGELOG.md.

Written by Cursor Bugbot for commit 4c1d21b. This will update automatically on new commits. Configure here.

Adds tests to verify that `lando --help` and tooling commands do not
include ANSI escape codes when stdout is redirected to a file.

These tests are expected to FAIL until the fix is applied — changing
TTY allocation to check `process.stdout.isTTY` instead of
`process.stdin.isTTY` in compose.js and build-docker-exec.js.

Ref #345
@netlify
Copy link

netlify bot commented Feb 28, 2026

Deploy Preview for lando-core ready!

Name Link
🔨 Latest commit 4c1d21b
🔍 Latest deploy log https://app.netlify.com/projects/lando-core/deploys/69a25702d1aa790008831292
😎 Deploy Preview https://deploy-preview-441--lando-core.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
1 paths audited
Performance: 89 (🟢 up 9 from production)
Accessibility: 89 (no change from production)
Best Practices: 92 (no change from production)
SEO: 90 (no change from production)
PWA: -
View the detailed breakdown and full score reports

To edit notification comments on pull requests, go to your Netlify project configuration.

The leia integration tests can't reproduce the bug in CI because
GitHub Actions doesn't allocate a real PTY, so stdin.isTTY is always
false. Replaced with unit tests that mock process.stdin.isTTY and
process.stdout.isTTY to validate both compose.js and
build-docker-exec.js TTY allocation logic.

Failing tests:
- compose: should set noTTY=true when stdout is not a TTY
- docker exec: should not include --tty when stdout is not a TTY

These fail because both files check stdin.isTTY but ignore stdout.isTTY.

Ref #345
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Stale require cache causes first compose test to silently mistest
    • Added beforeEach hook to clear require cache before each test, ensuring fresh module evaluation with test-set TTY values.

Create PR

Or push these changes by commenting:

@cursor push 65c38fe104
Preview (65c38fe104)
diff --git a/test/tty-allocation.spec.js b/test/tty-allocation.spec.js
--- a/test/tty-allocation.spec.js
+++ b/test/tty-allocation.spec.js
@@ -22,6 +22,12 @@
   const originalStdinIsTTY = process.stdin.isTTY;
   const originalStdoutIsTTY = process.stdout.isTTY;
 
+  beforeEach(() => {
+    // Clear require cache so compose.js re-evaluates with test-set TTY values
+    delete require.cache[require.resolve('./../lib/compose')];
+    delete require.cache[require.resolve('./../utils/build-docker-exec')];
+  });
+
   afterEach(() => {
     // Restore after each test
     process.stdin.isTTY = originalStdinIsTTY;

cursoragent and others added 2 commits February 28, 2026 00:54
Add beforeEach hook to clear require cache before each test runs.
This ensures each test gets a fresh module evaluation with the
correct TTY values, preventing the first test from using a stale
cached module loaded by compose.spec.js.
Changes TTY detection in both compose exec and direct docker exec to
check process.stdout.isTTY in addition to process.stdin.isTTY.

Previously, running `lando foo > file.txt` from a terminal would
allocate a TTY inside the container (because stdin was a TTY), causing
commands like composer to emit ANSI escape codes into the output file.

Now TTY is only allocated when both stdin AND stdout are terminals,
matching the expected behavior: colors in interactive use, clean output
when redirected.

Fixes #345
@AaronFeledy AaronFeledy changed the title test: validate no ANSI escape codes in redirected output fix: only allocate TTY when both stdin and stdout are terminals Feb 28, 2026
@AaronFeledy
Copy link
Member Author

Tradeoff: TTY allocation with redirected stdout

What this changes

Previously, Lando allocated a TTY whenever stdin was a terminal. Now it requires both stdin and stdout to be terminals. This matches how OpenSSH behaves — ssh won't allocate a PTY when stdout isn't a terminal either.

The edge case

If someone pipes Lando output while expecting interactive behavior:

lando ssh | tee log.txt
lando mysql | tee debug.log

With this change, stdout is a pipe → no TTY allocated → no line editing, no tab completion, no arrow keys inside the container. Programs that need a TTY for input (mysql interactive prompt, vim, etc.) would fall back to non-interactive mode.

The current (pre-fix) behavior is wrong for output redirection (> file.txt) but correct for interactive use that happens to be piped (| tee).

How common is this?

Probably rare. Most users either:

  1. Run interactively in a terminal (both stdin+stdout are TTY → works fine)
  2. Redirect/pipe output for scripting (don't want a TTY → now works correctly)

The | tee pattern for debugging is the main casualty. Anyone hitting this can force TTY allocation via Docker directly or use script -qc 'lando ssh' log.txt.

Alternative considered

We could split the check — use stdout.isTTY for Docker's --tty flag (container's TTY) but keep stdin.isTTY for --interactive (stdin attachment). Docker treats these as separate flags for exactly this reason. This would preserve interactive input even with piped output, but the container would still see a non-TTY stdout and programs would suppress colors/formatting anyway.

Conclusion

The stdin && stdout approach is the established convention (matches SSH behavior) and fixes the reported issue cleanly. The edge case is uncommon and has workarounds. If users report problems, we can revisit with the split-flag approach.

@AaronFeledy
Copy link
Member Author

@cursor push 65c38fe

Add beforeEach hook to clear require cache before each test runs.
This ensures each test gets a fresh module evaluation with the
correct TTY values, preventing the first test from using a stale
cached module loaded by compose.spec.js.

Applied via @cursor push command
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.

shell control characters printed to all output

2 participants