Repository: harbor-framework/harbor
Issue: Bug: send_keys(block=True) hangs indefinitely with multi-line input containing apt commands
Root cause: Algorithmic Error

Hello harbor developers,

I raised a similar issue in [terminal-bench](https://github.com/laude-institute/terminal-bench/issues/1379) where the maintainers pointed me to harbor. I replicated the same setup in harbor and ran into the same issue. When we tried using harbor instead of terminal bench's harness with a Qwen based model to solve tasks, my project teammate (@bhaskar792) and I came across the following bug. I used the following task that I created using [Harbor docs](https://harborframework.com/docs/task-tutorial). We use the terminus agent. 

[test-env.zip](https://github.com/user-attachments/files/24303351/test-env.zip)

## Bug: `send_keys(block=True)` Hangs Indefinitely with Multi-line Input Containing `apt` Commands

### Summary

When calling `session.send_keys([multi_line_string], block=True)` where the string contains `apt install`, the method never returns and only partially executes the commands in the multi-line string.


### Root Cause

When `send_keys()` sends a multi-line string to tmux, the embedded newlines (`\n`) cause commands to execute immediately while the `tmux send-keys` function continues to send keystrokes to the tmux session. The problem seems to occur because:

1. **apt consumes stdin by design**: According to this [Debian bug #728775](https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=728775), `apt install` intentionally consume all available stdin to prevent race conditions where user input typed during package installation could be misinterpreted as answers to prompts.

2. **Buffered commands are consumed**: When tmux sends the command string in the input, the execution becomes:
   - `apt update` (executes due to embedded `\n`)
   - `apt install -y openjdk-11-jdk` (executes, **consumes remaining stdin buffer**)
   - `mkdir -p /opt/hadoop; tmux wait -S done` (**never executes - was consumed by apt**)

3. **Signal is lost**: Since `tmux wait -S done` never executes, Python waits indefinitely at `tmux wait done` in `_send_blocking_keys()`.


## Impact: Potential for incorrect evaluation of LLM performance

Any LLM generating keystrokes that include multiple commands with interleaved newlines can incorrectly raise time-out errors and ignore all the subsequent commands. Incomplete execution of all the generated commands will impact the accuracy of LLMs on this benchmark. 


### Reproduction

We can reproduce the issue by storing the following code in a file in the root directory of harbor and executing `uv run file.py`.

```python
import asyncio
from pathlib import Path

from harbor.environments.docker.docker import DockerEnvironment
from harbor.agents.terminus_2.tmux_session import TmuxSession
from harbor.models.task.config import TaskConfig
from harbor.models.trial.paths import TrialPaths

INPUT = """
apt update
apt install -y openjdk-11-jdk
mkdir -p /opt/hadoop
"""

async def main():
    task_dir = Path("test-env")
    environment_dir = task_dir / "environment"
    output_path = Path("./demo-output")
    trial_name = "demo-session"

    output_path.mkdir(parents=True, exist_ok=True)

    task_config_path = task_dir / "task.toml"
    task_config = TaskConfig.model_validate_toml(task_config_path.read_text())

    trial_dir = output_path / trial_name
    trial_paths = TrialPaths(trial_dir=trial_dir)
    trial_paths.mkdir()

    environment = DockerEnvironment(
        environment_dir=environment_dir,
        environment_name="demo-env",
        session_id=trial_name,
        trial_paths=trial_paths,
        task_env_config=task_config.environment,
        keep_containers=False,
    )

    try:
        await environment.start(force_build=False)

        session = TmuxSession(
            session_name="demo",
            environment=environment,
            logging_path=Path("/tmp/demo.pane"),
            local_asciinema_recording_path=trial_paths.agent_dir / "recording.cast",
            remote_asciinema_recording_path=Path("/tmp/recording.cast"),
            pane_width=160,
            pane_height=40,
        )

        await session.start()
        await asyncio.sleep(1)

        await session.send_keys(["echo 'Test command'", "Enter"])
        pane_output = await session.capture_pane(capture_entire=True)
        print("Captured output:")
        print(pane_output.strip())

        await session.send_keys([INPUT], block=True)
        pane_output = await session.capture_pane(capture_entire=True)

        print("Captured output:")
        print(pane_output.strip())

        while True:
            user_command = input("\nEnter next command or type EXIT to exit the program: ")
            if user_command == 'EXIT':
                break
            await session.send_keys([user_command, "Enter"], block=True)

            final_output = await session.capture_pane(capture_entire=True)
            print("\nterminal state:")
            print(final_output.strip())

    finally:
        await session.stop()
        await environment.stop(delete=True)


if __name__ == "__main__":
    asyncio.run(main())

```

### Verification

Attaching to the Docker container and checking the tmux session confirms that `apt` commands completed but the final `mkdir` and signal command never executed, they were likely consumed by the stdin of the `apt` process. Please find the attached screenshot below.

<img width="2368" height="1036" alt="Image" src="https://github.com/user-attachments/assets/58736692-f785-45f6-a7e5-b79588a1352e" />
<img width="1848" height="764" alt="Image" src="https://github.com/user-attachments/assets/1d4c1bae-6f2b-49b8-a460-9118da5215f7" />


## Proposed fix

We can modify the send_keys function to detect newlines in the command keystroke strings and split them before execution.