diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc9e37e..7298f67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,46 @@ jobs: command -v "$tool" >/dev/null || { echo "::error::$tool is missing, so its tests were skipped"; exit 1; } done + linux: + # Tervin has only ever been run on macOS. The code is written for Unix generally + # and nothing in the PTY layer is macOS-specific, but "should work" is not a + # claim worth making, so this job exists to find out. README still says macOS is + # the tested platform, and that does not change until a person has used Linux for + # a day: a green CI run proves the tests pass, not that the product is usable. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + + - name: System dependencies + # Tauri links against the system webview, so even `cargo test` needs these: + # tervin-app is part of the workspace. `zsh` and `vim` are here because the + # terminal tests drive them and skip silently when they are missing, which + # would make this job green while proving very little. + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev \ + librsvg2-dev patchelf zsh vim + + - name: Confirm the tools the terminal tests need are present + # Before the tests rather than after, so a missing tool reads as setup being + # wrong rather than as a test failure. + run: | + for tool in vim less zsh; do + command -v "$tool" >/dev/null || { echo "::error::$tool is missing, so its tests would be skipped"; exit 1; } + done + + - name: Tests + run: cargo test --workspace + + - name: Clippy + # Separate from the macOS job because `cfg` differences mean each platform + # compiles code the other does not, so a lint can be clean on one and not the + # other. + run: cargo clippy --workspace --all-targets -- -D warnings + ui: runs-on: ubuntu-latest steps: diff --git a/README.md b/README.md index 5059678..170a11e 100644 --- a/README.md +++ b/README.md @@ -304,7 +304,11 @@ What is deliberately incomplete, and tracked rather than hidden: user's shell all have real costs, and picking wrong is worse than waiting. - **SSH latency and reconnect indicators.** SSH exposes no round-trip time, so a number here would be a measurement of something else wearing a latency label. -- **Linux and Windows.** Not claimed, because not exercised. +- **Linux.** Not yet claimed. CI now runs the suite there; the platform claim changes only after + a person has actually used it for a day. +- **Windows.** Not a target for now. Deferred rather than refused: the blocker is shell + integration, which has no `ZDOTDIR` equivalent, not the terminal itself. Reasoning in + [COMPETITIVE-SPEC.md](docs/COMPETITIVE-SPEC.md) §6. The commit history is the honest record: several commits exist because a test caught the implementation, and a few because a test was itself wrong. Both are labelled as such. diff --git a/SECURITY.md b/SECURITY.md index 2823456..925b8e3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -180,8 +180,9 @@ right-click, Open, once, or switch to a route that never triggers it. Stated rather than omitted: -- **Linux is untested.** The code is written for Unix generally, but untested is not - supported. +- **Linux is not yet claimed as supported.** The code is written for Unix generally; CI now + runs the suite on Linux, but CI green is not the same as supported. The platform claim + changes only after a person has actually used it for a day. - **A malicious agent is not contained.** Tervin gates what it is asked about. An agent that finds a path Tervin does not mediate, through a command that spawns another process, for instance, is limited by your OS, not by Tervin. Use the OS mechanisms diff --git a/crates/terminal-core/tests/pty_roundtrip.rs b/crates/terminal-core/tests/pty_roundtrip.rs index 68529a1..7f6f645 100644 --- a/crates/terminal-core/tests/pty_roundtrip.rs +++ b/crates/terminal-core/tests/pty_roundtrip.rs @@ -87,6 +87,22 @@ fn run(program: &str, args: &[&str], input: &[&str], done: impl Fn(&str) -> bool collected } +/// Write a word so the terminal's echo of it cannot pass for the shell's output. +/// +/// A PTY echoes what is typed. A test that types `echo FOO` and then waits for +/// `FOO` is therefore satisfied by its own echo, before the shell has run at all, +/// and every assertion after it is reading the input back. Splitting the word with +/// an empty quote leaves the echo reading `F''OO` while the shell still prints +/// `FOO`, so only real output can match. +/// +/// This is not hypothetical. Every marker in this file was written the plain way, +/// which is why the burst test stopped collecting after 81 bytes and then reported +/// the missing lines as though the pump had dropped them. +fn only_in_output(word: &str) -> String { + let (head, tail) = word.split_at(1); + format!("{head}''{tail}") +} + /// Strip escape sequences so assertions match what a user would read. fn plain(text: &str) -> String { let mut out = String::with_capacity(text.len()); @@ -135,7 +151,8 @@ fn a_real_shell_receives_input_and_returns_output() { // The input path: keystrokes written to the PTY must reach the shell and its // output must come back through the pump. A screenshot of a prompt proves // only the output half. - let collected = run("/bin/sh", &[], &["echo tervin-roundtrip-ok\n"], |text| { + let line = format!("echo {}\n", only_in_output("tervin-roundtrip-ok")); + let collected = run("/bin/sh", &[], &[&line], |text| { text.contains("tervin-roundtrip-ok") }); let text = plain(&collected.text); @@ -148,12 +165,12 @@ fn a_real_shell_receives_input_and_returns_output() { #[test] fn output_arrives_in_order_across_several_commands() { // The coalescer batches reads; batching must never reorder them. - let collected = run( - "/bin/sh", - &[], - &["echo one\n", "echo two\n", "echo three\n"], - |text| text.contains("three"), - ); + let lines: Vec = ["one", "two", "three"] + .iter() + .map(|w| format!("echo {}\n", only_in_output(w))) + .collect(); + let refs: Vec<&str> = lines.iter().map(String::as_str).collect(); + let collected = run("/bin/sh", &[], &refs, |text| text.contains("three")); let text = plain(&collected.text); let one = text.find("one"); let two = text.find("two"); @@ -172,12 +189,13 @@ fn output_arrives_in_order_across_several_commands() { fn a_large_burst_of_output_arrives_intact() { // The pump flushes early at a size threshold; nothing may be dropped at the // boundary. 5000 lines crosses it many times over. - let collected = run( - "/bin/sh", - &[], - &["i=0; while [ $i -lt 5000 ]; do echo line-$i; i=$((i+1)); done; echo BURST-DONE\n"], - |text| text.contains("BURST-DONE"), + let script = format!( + "i=0; while [ $i -lt 5000 ]; do echo line-$i; i=$((i+1)); done; echo {}\n", + only_in_output("BURST-DONE"), ); + let collected = run("/bin/sh", &[], &[&script], |text| { + text.contains("BURST-DONE") + }); let text = plain(&collected.text); assert!(text.contains("BURST-DONE"), "burst never completed"); for probe in ["line-0", "line-2500", "line-4999"] { @@ -192,14 +210,17 @@ fn a_large_burst_of_output_arrives_intact() { fn shell_integration_markers_survive_the_round_trip() { // Emitted by the shell, extracted by the tap, delivered on the chunk. If this // breaks, Blocks silently stop forming. - let script = concat!( - r#"printf '\033]7373;cmd=ZWNobyBoaQ==\007';"#, - r#"printf '\033]133;C\007';"#, - "echo hi;", - r#"printf '\033]133;D;0\007';"#, - "echo MARKERS-DONE\n", + let script = format!( + concat!( + r#"printf '\033]7373;cmd=ZWNobyBoaQ==\007';"#, + r#"printf '\033]133;C\007';"#, + "echo hi;", + r#"printf '\033]133;D;0\007';"#, + "echo {}\n", + ), + only_in_output("MARKERS-DONE"), ); - let collected = run("/bin/sh", &[], &[script], |text| { + let collected = run("/bin/sh", &[], &[&script], |text| { text.contains("MARKERS-DONE") }); diff --git a/crates/tervin-app/tests/blocks_end_to_end.rs b/crates/tervin-app/tests/blocks_end_to_end.rs index d2ef605..3329ffd 100644 --- a/crates/tervin-app/tests/blocks_end_to_end.rs +++ b/crates/tervin-app/tests/blocks_end_to_end.rs @@ -15,13 +15,27 @@ use shell_integration::{InjectionMode, Shell}; use std::sync::mpsc; use std::sync::Arc; use std::time::{Duration, Instant}; -use terminal_core::{PtyConfig, PtyEvent}; +use terminal_core::{PtyConfig, PtyEvent, ShellSignal}; use tervin_core::{PaneId, SessionId}; /// Generous, because a login shell sources the user's rc files — which on a real /// machine can mean a version manager and a completion framework. const TIMEOUT: Duration = Duration::from_secs(30); +/// How long after `133;A` to treat a shell that sends no `133;B` as ready. +const PROMPT_SETTLE: Duration = Duration::from_millis(300); + +/// Longest to wait for a prompt before deciding this shell does not report them. +/// +/// Bounded separately from [`TIMEOUT`] because it is a different question. Waiting +/// the full timeout for a signal that is never coming is what turned this file from +/// a 32-second run into a 90-second one. +const PROMPT_WAIT: Duration = Duration::from_secs(5); + +/// Fallback spacing for a shell that reports no prompts, as this file used +/// throughout before it learned to wait for one. +const BLIND_SETTLE: Duration = Duration::from_millis(400); + /// A scratch directory that cleans itself up. struct Scratch(std::path::PathBuf); @@ -89,17 +103,25 @@ fn blocks_from(shell_program: &str, shell: Shell, commands: &[&str]) -> Vec = Vec::new(); + + // Wait for the shell to say it is ready, rather than betting on how long that + // takes. One command at a time, each typed into a drawn prompt, so each forms + // its own Block. + // + // A shell that does not report prompts at all — bash 3.2, which macOS still + // ships — is asked once and then left alone, because asking again only buys + // another wait for an answer that is not coming. + let mut reports_prompts = wait_until_reading(&session, &rx, &mut builder, &mut finished); for command in commands { session.write(command.as_bytes()).expect("write failed"); - // One at a time, so each produces its own Block rather than being typed - // into a shell that has not yet drawn a new prompt. - std::thread::sleep(Duration::from_millis(400)); + if reports_prompts { + reports_prompts = wait_for_prompt(&rx, &mut builder, &mut finished); + } else { + std::thread::sleep(BLIND_SETTLE); + } } - let mut finished: Vec = Vec::new(); let deadline = Instant::now() + TIMEOUT; while Instant::now() < deadline && finished.len() < commands.len() { @@ -128,6 +150,108 @@ fn blocks_from(shell_program: &str, shell: Shell, commands: &[&str]) -> Vec, + builder: &mut BlockBuilder, + finished: &mut Vec, +) -> bool { + // A shell that never reports a prompt cannot be asked to prove anything. + if !wait_for_prompt(rx, builder, finished) { + return false; + } + for _ in 0..5 { + if session.write(b"\n").is_err() { + return false; + } + if wait_for_prompt(rx, builder, finished) { + return true; + } + } + false +} + +/// Drain events until the shell has drawn a prompt, keeping any Blocks that finish. +/// +/// A fixed sleep here is a bet on how fast someone else's machine starts a shell, +/// and losing that bet is not merely slow. zsh's line editor calls `tcsetattr` with +/// a flush when it initialises, which discards whatever has already been typed, so +/// a sleep that is fractionally too short does not delay the input — it eats the +/// front of it. The Linux runner produced `cho` where this test typed `echo`, and +/// reported it as a command that "did not survive the round trip", which points at +/// the marker pipeline rather than at the clock. +/// +/// `PromptEnd` is OSC 133;B, which zsh's injected integration emits once the prompt +/// is drawn and the shell is ready to read. Waiting for it is exact: measured here +/// at 485ms for the first prompt and 17ms for later ones, against a 1200ms guess. +/// +/// Bash never sends it in this configuration. It is a login shell, and the marker +/// is appended to `PS1` by the injected rc file, so anything that sets `PS1` +/// afterwards drops it. Its `133;A` still arrives, so that plus a short settle is +/// what bash gets. Worth knowing beyond this test: any feature keyed on `PromptEnd` +/// is not getting one from bash. +fn wait_for_prompt( + rx: &mpsc::Receiver, + builder: &mut BlockBuilder, + finished: &mut Vec, +) -> bool { + let deadline = Instant::now() + PROMPT_WAIT; + let mut prompt_started: Option = None; + + while Instant::now() < deadline { + match rx.recv_timeout(Duration::from_millis(50)) { + Ok(PtyEvent::Chunk(chunk)) => { + let mut ready = false; + for positioned in &chunk.signals { + match positioned.signal { + // Definitive: the prompt is drawn and the shell is reading. + ShellSignal::PromptEnd => ready = true, + ShellSignal::PromptStart => { + prompt_started.get_or_insert_with(Instant::now); + } + _ => {} + } + } + for event in builder.consume(&chunk) { + if let BlockEvent::Finished(block) = event { + finished.push(block); + } + } + if ready { + return true; + } + } + Ok(PtyEvent::Exited { .. }) => return false, + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => return false, + } + + // A shell that announced a prompt but sends no `133;B` still told us + // something. Take it, once it has had a moment to finish drawing. + if prompt_started.is_some_and(|t| t.elapsed() >= PROMPT_SETTLE) { + return true; + } + } + false +} + /// Small helper so the sink closure stays readable at the call site. fn spawn_session(config: PtyConfig, tx: mpsc::Sender) -> terminal_core::PtySession { terminal_core::PtySession::spawn( diff --git a/docs/COMPETITIVE-SPEC.md b/docs/COMPETITIVE-SPEC.md index b256fdd..6f2ac78 100644 --- a/docs/COMPETITIVE-SPEC.md +++ b/docs/COMPETITIVE-SPEC.md @@ -36,7 +36,7 @@ the transcript they already write. Commands an agent runs become Blocks. reason on every absence. `exit_code_reported` so a derived exit status is never shown as a measured one. Tervin never answers `allow` on a runtime's behalf. -**Not present, and worth saying:** no Linux or Windows build, no signing (§5, a decision +**Not present, and worth saying:** no Linux build (in CI; not yet claimed), no signing (§5, a decision rather than a gap), no kitty keyboard or graphics protocol, no scripting API, no plugin system, no team or sync features, no instant replay, no broadcast input, no vi-mode scrollback, no tmux control mode, no CLI flag @@ -57,7 +57,7 @@ specific matters more than being reassuring. | --- | --- | --- | | **Cloud agents (Oz)** | Event-triggered autonomous agents in containers, reacting to webhooks, CI, cron and Slack. 20 to 40 concurrent depending on tier. | **Close it, differently.** See §4. | | **Warp Drive** | Team-synced storage of workflows, notebooks, environment profiles and MCP server lists. | **Close the local half, refuse the cloud half.** See §4.4. | -| **Cross-platform** | GA on macOS, Linux (X11 and Wayland) and Windows, including ARM64. | **Close it.** §3.1. | +| **Cross-platform** | GA on macOS, Linux (X11 and Wayland) and Windows, including ARM64. | **Linux: close it.** §3.1. **Windows: deferred.** §6. | | **CLI flag completion** | Subcommand and flag completion for hundreds of CLIs, no plugin needed. | **Close it.** §3.2. | | **Blocks over SSH** | Shell integration, blocks and AI survive an SSH hop and subshells (`nvm`, venv, `docker exec`, `kubectl exec`). | **Close it.** §3.3. | | **Notebook blocks** | A command, its output and prose, shared as a link with execution context. | Partly. §4.4. | @@ -222,14 +222,13 @@ it. **§4.2: small work, real interoperability.** Ordered by value per unit of work, not by section number. -### 3.1 Linux and Windows builds +### 3.1 Linux build `P1.` The code is Unix-general already and the PTY layer has no macOS-specific assumptions; -the honest blocker is that nothing has been *run* there. Add both to CI first, fix what -breaks, and only then claim support. Windows needs ConPTY behind the `portable-pty` -abstraction and a decision about shell integration, which has no equivalent to `ZDOTDIR`. +the honest blocker is that nothing has been *run* there. Add Linux to CI first, fix what +breaks, and only then claim support. -*Exit criteria:* CI green on all three, and the README's platform claim changes only after a -human has actually used each for a day. +*Exit criteria:* CI green on both, and the README's platform claim changes only after a +human has actually used Linux for a day. ### 3.2 CLI flag and subcommand completion `P1.` The largest remaining Warp gap. Three approaches, and this specification picks one: @@ -543,7 +542,7 @@ Naming these matters as much as the roadmap, because each is a plausible request ## 6. Ordered plan -**Now: credibility.** Linux and Windows in CI (§3.1). Read `AGENTS.md` and existing MCP +**Now: credibility.** Linux in CI (§3.1). Read `AGENTS.md` and existing MCP config (§4.2). CLI completion via the shell (§3.2). **Next: the two structural gaps.** Detach and reattach (§3.8). Parallel Threads with worktree @@ -560,6 +559,14 @@ a cloud agent's PR being the part nobody else has (§4.3). Local models as real **Later, or never.** Floating panes, layout artefacts, history sync, the MCP server, instant replay. Each is defensible; none changes the argument for using Tervin. +**Windows: deferred, not refused.** ConPTY covers the PTY layer behind `portable-pty`, so the +terminal itself is tractable. Shell integration is the blocker: there is no equivalent to +`ZDOTDIR`, so the automatic injection in §3.1 does not carry over, and the completion design +in §3.2 assumes a Unix shell. Tervin's scope is macOS and Linux, which is coherent because +both share those assumptions. This is in the roadmap rather than in §5 deliberately: nothing +about the design forecloses Windows, and the cost is a shell-integration story nobody has +written yet rather than a decision against it. + --- ## Sources