diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 68060c2..7c30ff5 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "conn", "source": "./plugin", "description": "Conn: MCP server + working-etiquette skill.", - "version": "0.8.7" + "version": "0.8.8" } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index c95bc7c..95f5ab6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## Unreleased +## 0.8.8 — Preview · 2026-09-23 + +- Keep the complete agent command through supported cursor edits, including wrapped input and colored/wide prompts. History, completion and uncertain edits no longer let a screen-row fragment determine approval for a different physical command. +- Separate cancelling permission from clearing terminal input. A confirmed idle shell is interrupted and observed before reuse; other foreground programs retain the visible line for explicit recovery. New agent commands cannot silently attach to that remainder. Human typing cancels review and takes over the visible line. +- Return PTY write/flush failures as an uncertain outcome without replaying input. Publish a new terminal size only after the local PTY confirms it, and surface resize failures. +- Ignore obsolete screen-restoration callbacks. A delayed restoration shows an input-paused status and an explicit screen recovery action. Add real Vim edit/save, reconnect and long-buffer regressions. + +한국어: 긴 명령과 색상·한글 프롬프트에서 커서를 편집한 뒤에도 승인 대상 전체 명령을 보존합니다. 불확실한 히스토리·자동완성 결과를 화면 한 행으로 추정해 실행하지 않습니다. 권한 취소와 입력 정리를 분리해 거절 뒤 남은 문자열에 새 명령이 이어 붙지 않게 했으며, 사람 입력은 승인을 취소하고 보이는 입력을 직접 이어받습니다. PTY 입력·크기 변경 실패를 전달하고, 오래된 화면 복원 콜백을 무시하며, 복원 지연 시 상태와 복구 동작을 표시합니다. + +Validation and limits: [release verification](docs/release-0.8.8-validation.md). Linux tests exercise the production common UI, real Rust PTYs, persistent MCP, disposable OpenSSH and real Vim. **The user's macOS Vim freeze and SSH gateway rendering issue have not been reproduced or confirmed fixed on that Mac.** Native build/signing/notarization and package checks are separate from interactive Mac acceptance. Remote prompt configuration, UTF-8 locale, physical IME and installed-app upgrades remain separate validation boundaries. + +한국어: [검증 기록](docs/release-0.8.8-validation.ko.md)에 범위를 구분했습니다. **사용자 Mac의 Vim 무응답과 실제 SSH 게이트웨이 화면 문제를 해당 Mac에서 재현·해결했다고 주장하지 않습니다.** 배포 서명·공증·패키지 검사는 대화형 실기 검증과 다릅니다. + ## 0.8.7 — Preview · 2026-09-23 - Ship the shared UI/runtime refactor: native and standalone web mount the same Svelte app and use the same Rust owner, input ordering, permission decisions and lifecycle. A Linux x64 web archive includes the host, matching MCP CLI and UI; it runs locally without a build toolchain. diff --git a/Cargo.lock b/Cargo.lock index 7a2f521..d82826f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -696,7 +696,7 @@ dependencies = [ [[package]] name = "conn" -version = "0.8.7" +version = "0.8.8" dependencies = [ "clap", "conn-core", @@ -706,8 +706,9 @@ dependencies = [ [[package]] name = "conn-core" -version = "0.8.7" +version = "0.8.8" dependencies = [ + "anyhow", "chrono", "dirs 5.0.1", "globset", @@ -729,7 +730,7 @@ dependencies = [ [[package]] name = "conn-desktop" -version = "0.8.7" +version = "0.8.8" dependencies = [ "base64 0.22.1", "cc", @@ -751,7 +752,7 @@ dependencies = [ [[package]] name = "conn-frontend" -version = "0.8.7" +version = "0.8.8" dependencies = [ "base64 0.22.1", "conn-core", @@ -768,7 +769,7 @@ dependencies = [ [[package]] name = "conn-web" -version = "0.8.7" +version = "0.8.8" dependencies = [ "axum", "base64 0.22.1", diff --git a/Cargo.toml b/Cargo.toml index 61e4ae2..f72a000 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,14 +6,14 @@ members = ["crates/core", "crates/cli", "crates/frontend", "crates/web", "fronte default-members = ["crates/core", "crates/cli", "crates/frontend", "crates/web"] [workspace.package] -version = "0.8.7" +version = "0.8.8" edition = "2021" license = "MIT" repository = "https://github.com/eggp-dev/conn" [workspace.dependencies] -conn-core = { path = "crates/core", version = "0.8.7" } -conn-frontend = { path = "crates/frontend", version = "0.8.7" } +conn-core = { path = "crates/core", version = "0.8.8" } +conn-frontend = { path = "crates/frontend", version = "0.8.8" } tokio = { version = "1", features = ["rt-multi-thread", "net", "io-util", "sync", "time", "macros", "signal"] } portable-pty = "0.8" vt100 = { path = "vendor/vt100", version = "0.15.2" } diff --git a/README.ko.md b/README.ko.md index 3668d4f..f9a71e7 100644 --- a/README.ko.md +++ b/README.ko.md @@ -29,13 +29,13 @@ curl -fsSL https://conn.eggp.dev/install.sh | sh Rust나 Node.js는 필요 없습니다. 에이전트 연결 도구가 함께 들어 있습니다. 두 방법 모두 아래 다운로드와 같은 서명된 릴리스 파일을 설치하며, 이후에는 앱 안에서 업데이트됩니다. Windows는 설치 파일을 사용하세요. -| 운영체제 | v0.8.7 프리뷰 | +| 운영체제 | v0.8.8 프리뷰 | |---|---| -| macOS · Apple Silicon | [Mac용 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.8.7/conn-v0.8.7-aarch64-apple-darwin-desktop.dmg) | -| Windows · x64 | [설치 파일 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.8.7/conn-v0.8.7-x86_64-pc-windows-msvc-setup.exe) | -| Ubuntu · x64 | [.deb 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.8.7/conn-v0.8.7-x86_64-unknown-linux-gnu-desktop.deb) · [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.7/conn-v0.8.7-x86_64-unknown-linux-gnu-desktop.AppImage) | +| macOS · Apple Silicon | [Mac용 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.8.8/conn-v0.8.8-aarch64-apple-darwin-desktop.dmg) | +| Windows · x64 | [설치 파일 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.8.8/conn-v0.8.8-x86_64-pc-windows-msvc-setup.exe) | +| Ubuntu · x64 | [.deb 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.8.8/conn-v0.8.8-x86_64-unknown-linux-gnu-desktop.deb) · [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.8/conn-v0.8.8-x86_64-unknown-linux-gnu-desktop.AppImage) | -Mac 배포 파일은 서명·공증되어 있습니다. Windows 프리뷰는 무서명이며, Linux는 Ubuntu 24.04에서 빌드하며, AppImage는 앱 안에서 업데이트되고 `.deb`는 새 패키지를 설치해 업데이트합니다. Intel Mac 배포는 잠시 중단했습니다. [설치·업데이트 안내](docs/getting-started.ko.md) · [전체 파일·체크섬](https://github.com/eggp-dev/conn/releases/tag/v0.8.7) +Mac 배포 파일은 서명·공증되어 있습니다. Windows 프리뷰는 무서명이며, Linux는 Ubuntu 24.04에서 빌드하며, AppImage는 앱 안에서 업데이트되고 `.deb`는 새 패키지를 설치해 업데이트합니다. Intel Mac 배포는 잠시 중단했습니다. [설치·업데이트 안내](docs/getting-started.ko.md) · [전체 파일·체크섬](https://github.com/eggp-dev/conn/releases/tag/v0.8.8) ## 처음 5분 diff --git a/README.md b/README.md index 6a1c90f..f1d3cc2 100644 --- a/README.md +++ b/README.md @@ -29,13 +29,13 @@ curl -fsSL https://conn.eggp.dev/install.sh | sh No Rust or Node.js required; the agent connector is included. Both install the same signed release files as the downloads below, and the app updates itself afterwards. Windows: use the installer. -| Platform | v0.8.7 preview | +| Platform | v0.8.8 preview | |---|---| -| macOS · Apple Silicon | [Download for Mac](https://github.com/eggp-dev/conn/releases/download/v0.8.7/conn-v0.8.7-aarch64-apple-darwin-desktop.dmg) | -| Windows · x64 | [Download installer](https://github.com/eggp-dev/conn/releases/download/v0.8.7/conn-v0.8.7-x86_64-pc-windows-msvc-setup.exe) | -| Ubuntu · x64 | [Download .deb](https://github.com/eggp-dev/conn/releases/download/v0.8.7/conn-v0.8.7-x86_64-unknown-linux-gnu-desktop.deb) · [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.7/conn-v0.8.7-x86_64-unknown-linux-gnu-desktop.AppImage) | +| macOS · Apple Silicon | [Download for Mac](https://github.com/eggp-dev/conn/releases/download/v0.8.8/conn-v0.8.8-aarch64-apple-darwin-desktop.dmg) | +| Windows · x64 | [Download installer](https://github.com/eggp-dev/conn/releases/download/v0.8.8/conn-v0.8.8-x86_64-pc-windows-msvc-setup.exe) | +| Ubuntu · x64 | [Download .deb](https://github.com/eggp-dev/conn/releases/download/v0.8.8/conn-v0.8.8-x86_64-unknown-linux-gnu-desktop.deb) · [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.8/conn-v0.8.8-x86_64-unknown-linux-gnu-desktop.AppImage) | -Mac downloads are signed and notarized. Windows previews are unsigned. Linux builds use Ubuntu 24.04, and the AppImage updates in-app while the `.deb` updates by installing a new package; Intel Mac distribution is paused. [Installation and updates](docs/getting-started.md) · [All assets and checksums](https://github.com/eggp-dev/conn/releases/tag/v0.8.7) +Mac downloads are signed and notarized. Windows previews are unsigned. Linux builds use Ubuntu 24.04, and the AppImage updates in-app while the `.deb` updates by installing a new package; Intel Mac distribution is paused. [Installation and updates](docs/getting-started.md) · [All assets and checksums](https://github.com/eggp-dev/conn/releases/tag/v0.8.8) ## First five minutes diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 34936d8..29f1e2f 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -25,6 +25,7 @@ parking_lot.workspace = true globset.workspace = true [dev-dependencies] +anyhow = "1" tempfile.workspace = true [target.'cfg(windows)'.dependencies] diff --git a/crates/core/src/engine.rs b/crates/core/src/engine.rs index ee12793..68aa3f1 100644 --- a/crates/core/src/engine.rs +++ b/crates/core/src/engine.rs @@ -309,8 +309,8 @@ impl Engine { } /// Human keyboard input. Revokes any agent lease first. - pub fn write_input(&self, bytes: &[u8]) { - self.session.lock().human_input(bytes); + pub fn write_input(&self, bytes: &[u8]) -> Result<(), crate::session::SessionError> { + self.session.lock().human_input(bytes) } /// Deliver one bounded native external-input chunk. No command tracking. diff --git a/crates/core/src/human_input.rs b/crates/core/src/human_input.rs index 4cea81f..43a4323 100644 --- a/crates/core/src/human_input.rs +++ b/crates/core/src/human_input.rs @@ -14,6 +14,11 @@ impl HumanInput { pub fn remember_empty(&mut self, screen: &ScreenModel) { self.boundary = screen.input_boundary(); self.observed = false; } + pub fn begin_cleanup(&mut self, screen: &ScreenModel) { + self.pending = true; + self.reconcile = true; + self.observed |= self.boundary.is_some() && self.boundary != screen.input_boundary(); + } pub fn reset(&mut self) { *self = Self::default(); } pub fn note(&mut self, bytes: &[u8], screen: &ScreenModel, agent_pending: bool) { diff --git a/crates/core/src/input.rs b/crates/core/src/input.rs index 133f1f6..9804a43 100644 --- a/crates/core/src/input.rs +++ b/crates/core/src/input.rs @@ -1,181 +1,159 @@ -//! Input line tracker. Reconstructs the command line from the bytes that were -//! written to the PTY, regardless of who wrote them. -//! -//! Tab completion and history navigation make the shell redraw the line, which the -//! tracker cannot follow. Those mark the tracker `dirty`; the session then falls back -//! to the VT model's cursor line at ENTER time. +//! Agent-owned input only. Never reconstruct an uncertain command from one +//! terminal row: a wrapped row is not a shell command. Human input is tracked +//! separately without retaining its contents. -#[derive(Debug, Default)] +#[derive(Debug)] pub struct InputTracker { buf: Vec, pending: Vec, dirty: bool, + cursor: usize, + cursor_known: bool, prompt_col: Option, } +impl Default for InputTracker { + fn default() -> Self { + Self { + buf: Vec::new(), + pending: Vec::new(), + dirty: false, + cursor: 0, + cursor_known: true, + prompt_col: None, + } + } +} impl InputTracker { pub fn new() -> Self { Self::default() } - pub fn line(&self) -> String { self.buf.iter().collect() } - pub fn has_pending(&self) -> bool { !self.buf.is_empty() || !self.pending.is_empty() || self.dirty } - pub fn is_dirty(&self) -> bool { - self.dirty + self.dirty || !self.pending.is_empty() } - pub fn prompt_col(&self) -> Option { self.prompt_col } - + pub fn mark_uncertain(&mut self) { + self.dirty = true; + } pub fn reset(&mut self) { - self.buf.clear(); - self.pending.clear(); - self.dirty = false; - self.prompt_col = None; + *self = Self::new(); } - - fn note_first_byte(&mut self, cursor_col: u16) { - if self.prompt_col.is_none() { - self.prompt_col = Some(cursor_col); + fn edit(&mut self) { + if !self.cursor_known { + self.dirty = true; } } - /// Feed bytes written to the PTY. `cursor_col` is the VT cursor column *before* - /// these bytes were echoed; it is captured as the prompt width on the first byte - /// of a new line. pub fn feed(&mut self, bytes: &[u8], cursor_col: u16) { let mut i = 0; while i < bytes.len() { - let b = bytes[i]; - match b { + self.prompt_col.get_or_insert(cursor_col); + match bytes[i] { 0x1b => { - // Escape sequence: consume it, mark dirty (cursor movement / history). - self.note_first_byte(cursor_col); + // The named LEFT/RIGHT keys preserve the complete text. + // Non-ASCII readline movement varies with locale/graphemes; + // preserve the text, but require recovery before editing it. + if bytes[i..].starts_with(b"\x1b[D") || bytes[i..].starts_with(b"\x1b[C") { + self.cursor_known &= self.buf.iter().all(char::is_ascii); + if bytes[i + 2] == b'D' { + self.cursor = self.cursor.saturating_sub(1); + } else { + self.cursor = (self.cursor + 1).min(self.buf.len()); + } + i += 3; + continue; + } self.dirty = true; i += 1; - if i < bytes.len() && (bytes[i] == b'[' || bytes[i] == b'O') { + if i < bytes.len() && matches!(bytes[i], b'[' | b'O') { i += 1; while i < bytes.len() && !(0x40..=0x7e).contains(&bytes[i]) { i += 1; } } - i += 1; - continue; } - // ENTER submits the line and Ctrl-C discards it; either way it is gone. - b'\r' | b'\n' | 0x03 => self.reset(), - 0x15 => { - // Ctrl-U: kill line - self.flush_pending(); - self.buf.clear(); - self.dirty = false; + b'\r' | b'\n' | 3 => self.reset(), + 1 => { + self.cursor = 0; + self.cursor_known = true; } - 0x17 => { - // Ctrl-W: kill word - self.flush_pending(); - while self.buf.last().map(|c| c.is_whitespace()).unwrap_or(false) { - self.buf.pop(); + 5 => { + self.cursor = self.buf.len(); + self.cursor_known = true; + } + 21 => { + self.edit(); + self.buf.drain(..self.cursor); + self.cursor = 0; + } + 23 => { + self.edit(); + while self.cursor > 0 && self.buf[self.cursor - 1].is_whitespace() { + self.cursor -= 1; + self.buf.remove(self.cursor); } - while self.buf.last().map(|c| !c.is_whitespace()).unwrap_or(false) { - self.buf.pop(); + while self.cursor > 0 && !self.buf[self.cursor - 1].is_whitespace() { + self.cursor -= 1; + self.buf.remove(self.cursor); } } - 0x7f | 0x08 => { - self.flush_pending(); - self.buf.pop(); - } - b'\t' => { - self.note_first_byte(cursor_col); - self.dirty = true; + 127 | 8 => { + // Readline locales and grapheme-aware bindings can erase a + // different number of Unicode scalars. Do not certify that + // resulting physical command using our scalar buffer. + if self.buf.iter().any(|c| !c.is_ascii()) { self.dirty = true; } + self.edit(); + if self.cursor > 0 { + self.cursor -= 1; + self.buf.remove(self.cursor); + } } - 0x00..=0x1f => { - // Other control chars (Ctrl-A/E cursor moves etc.): tracking is unreliable. - self.note_first_byte(cursor_col); - if b != 0x04 { - self.dirty = true; + 4 => { + if self.buf.iter().any(|c| !c.is_ascii()) { self.dirty = true; } + self.edit(); + if self.cursor < self.buf.len() { + self.buf.remove(self.cursor); } } - _ => { - self.note_first_byte(cursor_col); + 0..=31 => self.dirty = true, + b => { + self.edit(); self.pending.push(b); - self.flush_complete_utf8(); + self.flush_utf8(); } } i += 1; } } - fn flush_complete_utf8(&mut self) { - loop { - match std::str::from_utf8(&self.pending) { - Ok(s) => { - self.buf.extend(s.chars()); - self.pending.clear(); - return; - } - Err(e) => { - let valid = e.valid_up_to(); - if valid > 0 { - let s = std::str::from_utf8(&self.pending[..valid]).unwrap(); - self.buf.extend(s.chars()); - self.pending.drain(..valid); - continue; - } - if e.error_len().is_some() { - // invalid byte: drop it - self.pending.remove(0); - continue; - } - return; // incomplete sequence, wait for more - } + fn flush_utf8(&mut self) { + match std::str::from_utf8(&self.pending) { + Ok(s) => { + let chars: Vec<_> = s.chars().collect(); + let len = chars.len(); + self.buf.splice(self.cursor..self.cursor, chars); + self.cursor += len; + self.pending.clear(); } - } - } - - fn flush_pending(&mut self) { - if !self.pending.is_empty() { - let s = String::from_utf8_lossy(&self.pending).to_string(); - self.buf.extend(s.chars()); - self.pending.clear(); + Err(e) if e.error_len().is_some() => { + self.dirty = true; + self.pending.clear(); + } + Err(_) => {} } } } -/// Decide the command text at ENTER time. -/// -/// * Not dirty → the tracked line. -/// * Dirty → the VT cursor line with the prompt prefix removed. If the two differ, -/// the VT model wins. Falls back to the tracked line when the VT line yields nothing. -pub fn resolve_command(tracked: &str, dirty: bool, cursor_line: &str, prompt_col: Option) -> String { - let from_vt = strip_prompt(cursor_line, prompt_col, tracked); - if !dirty { - return tracked.trim().to_string(); - } - match from_vt { - Some(v) if !v.trim().is_empty() => v.trim().to_string(), - _ => tracked.trim().to_string(), - } -} - -fn strip_prompt(cursor_line: &str, prompt_col: Option, tracked: &str) -> Option { - let chars: Vec = cursor_line.chars().collect(); - if let Some(col) = prompt_col { - let col = col as usize; - if col <= chars.len() { - return Some(chars[col..].iter().collect()); - } - } - // No prompt width known: if the tracked text appears at the end of the line, take it. - let t = tracked.trim(); - if !t.is_empty() && cursor_line.trim_end().ends_with(t) { - return Some(t.to_string()); - } - None +/// Only a complete, reliable agent-owned command can enter the policy gate. +/// Completion, history and unknown editing never fall back to screen fragments. +pub fn resolve_command(tracked: &str, dirty: bool) -> Option { + (!dirty).then(|| tracked.trim().to_string()) } diff --git a/crates/core/src/protocol.rs b/crates/core/src/protocol.rs index a7e3306..4b53740 100644 --- a/crates/core/src/protocol.rs +++ b/crates/core/src/protocol.rs @@ -34,6 +34,9 @@ impl From for RpcError { SessionError::Authority(AuthorityError::Expired) => "lease_expired", SessionError::ProcessExited => "process_exited", SessionError::InputPending => "input_pending", + SessionError::InputUnverified => "input_unverified", + SessionError::InputDeliveryUnknown => "input_outcome_unknown", + SessionError::ResizeFailed => "resize_failed", SessionError::InvalidInput(_) => "invalid_input", SessionError::NotFound(_) => "not_found", SessionError::ApprovalPending(_) => "approval_pending", diff --git a/crates/core/src/session.rs b/crates/core/src/session.rs index 9b6c292..911d9ae 100644 --- a/crates/core/src/session.rs +++ b/crates/core/src/session.rs @@ -193,6 +193,12 @@ pub enum SessionError { ProcessExited, #[error("shell input is pending; ask the human to finish or explicitly cancel their input before continuing")] InputPending, + #[error("input_unverified: the complete edited command is unknown; inspect the shared screen, explicitly cancel this input with interrupt, then type the complete command again")] + InputUnverified, + #[error("input_outcome_unknown: PTY input delivery failed; inspect and recover the shared shell before retrying")] + InputDeliveryUnknown, + #[error("PTY resize failed; the previous screen size is retained")] + ResizeFailed, #[error("invalid input: {0}")] InvalidInput(String), #[error("not found: {0}")] @@ -302,6 +308,8 @@ pub struct ScheduledExec { pub struct ApprovalInfo { #[serde(rename = "approvalId")] pub approval_id: String, + #[serde(rename = "inputRetained")] + pub input_retained: bool, pub state: ApprovalState, pub cmd: String, pub label: String, @@ -443,6 +451,7 @@ pub struct Session { approvals: ApprovalQueue, screen: ScreenModel, input: InputTracker, + cancelled_input: bool, human_input: crate::human_input::HumanInput, shell_integration: crate::shell_integration::IntegrationStatus, shell_command: Option, @@ -541,6 +550,7 @@ impl Session { approvals: ApprovalQueue::new(Duration::from_secs(cfg.pacing.approval_ttl_secs)), screen: ScreenModel::new(cfg.rows, cfg.cols), input: InputTracker::new(), + cancelled_input: false, human_input: Default::default(), shell_integration: crate::shell_integration::IntegrationStatus::unavailable(if external_private { "private_session" } else { "unsupported_shell" }), shell_command: None, @@ -722,6 +732,7 @@ impl Session { self.shell_submission_inflight = false; self.shell_idle_recovery = false; self.shell_prompt_confirmed = true; + if self.cancelled_input && !self.input.has_pending() { self.human_input.reset(); self.cancelled_input = false; } self.shell_recording_ready = self.participation.shared && self.shell_integration.state == "active"; } "start" => { @@ -894,7 +905,7 @@ impl Session { // ----- plumbing ------------------------------------------------------- - fn write_pty(&mut self, bytes: &[u8]) { + fn write_pty(&mut self, bytes: &[u8]) -> Result<(), SessionError> { if bytes.iter().any(|byte| matches!(byte, b'\r' | b'\n' | 3 | 4)) { // A late prompt event must not certify a command that has just been // submitted. Only cancellation/empty Enter at a confirmed idle @@ -908,8 +919,13 @@ impl Session { self.shell_submission_inflight = true; } if !self.is_private() { self.trace("pty_write"); } - let _ = self.pty.write_all(bytes); - let _ = self.pty.flush(); + if self.pty.write_all(bytes).and_then(|_| self.pty.flush()).is_err() { + self.input.mark_uncertain(); + self.cancelled_input = true; + self.audit.record("system", "input_delivery_unknown", json!({})); + return Err(SessionError::InputDeliveryUnknown); + } + Ok(()) } fn write_output(&mut self, bytes: &[u8]) { @@ -1017,15 +1033,15 @@ impl Session { // ----- human path ----------------------------------------------------- /// Bytes from the human. This is the preemptive takeover path. - pub fn human_input(&mut self, bytes: &[u8]) { + pub fn human_input(&mut self, bytes: &[u8]) -> Result<(), SessionError> { if !bytes.is_empty() { self.shell_human_input = true; } if self.is_private() { if !bytes.is_empty() { self.revoke_external(); self.human_input.note(bytes, &self.screen, self.input.has_pending()); } - if self.process_alive { self.write_pty(bytes); } - return; + if self.process_alive { self.write_pty(bytes)?; } + return Ok(()); } self.shell_recording_armed |= !bytes.is_empty() && self.shell_recording_ready && self.shell_prompt_confirmed; // 1. revoke lease 2. controller = Human (both inside `revoke`) @@ -1039,14 +1055,15 @@ impl Session { self.reject_proposal_if(|_| true, "human_input"); // Nor does a pending approval. Its command is already typed on the line, so the // human's bytes would be appended to it and a later "approve" would run a line - // nobody reviewed. Denying clears that line before the human's input arrives. + // nobody reviewed. Cancel approval before forwarding the human's edits; + // unknown foreground programs must not receive guessed clearing keys. if !bytes.is_empty() { for id in self.approvals.pending().into_iter().map(|a| a.id.clone()).collect::>() { self.finish_approval(&id, ApprovalState::Denied, "human_input"); } } // 3. forward the bytes - self.write_pty(bytes); + let delivered = self.write_pty(bytes); if let Some(lease) = revoked { // 4. tell the agent 5. audit let ev = ServerEvent::ControlRevoked { lease: lease.label(), agent_id: lease.agent_id.clone(), reason: RevokeReason::HumanInput }; @@ -1063,7 +1080,9 @@ impl Session { if !bytes.is_empty() { self.human_input.note(bytes, &self.screen, self.input.has_pending()); self.input.reset(); + if delivered.is_ok() { self.cancelled_input = false; } } + delivered } /// Revoke the agent lease without typing anything (`conn take`, frontend button). @@ -1086,18 +1105,24 @@ impl Session { let (changed, moved) = self.screen.process_observing(bytes, self.human_input.watch_column()); if changed { self.screen_dirty = true; } self.human_input.output(&self.screen, moved); + if self.cancelled_input && !self.input.has_pending() && !self.human_input.pending { self.cancelled_input = false; } self.write_output(bytes); } - pub fn resize(&mut self, rows: u16, cols: u16) { + pub fn resize(&mut self, rows: u16, cols: u16) -> Result<(), SessionError> { + let (rows, cols) = (rows.max(1), cols.max(1)); + // Do not publish dimensions that the PTY rejected. Output is applied + // under this same session lock, after the successful size marker. + if let Some(m) = &self.master { + m.resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 }) + .map_err(|_| SessionError::ResizeFailed)?; + let applied = m.get_size().map_err(|_| SessionError::ResizeFailed)?; + if applied.rows != rows || applied.cols != cols { return Err(SessionError::ResizeFailed); } + } self.screen.resize(rows, cols); self.screen_dirty = true; - // Order renderer dimensions with PTY bytes under the same session lock. - // An IPC acknowledgement alone can overtake output event delivery. self.emit_output_frame(&[]); - if let Some(m) = &self.master { - let _ = m.resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 }); - } + Ok(()) } pub fn process_exited(&mut self, exit_code: Option) { @@ -1451,7 +1476,7 @@ impl Session { if self.mode == mode { return Ok(()); } // A mode switch must not leave physical shell input behind a ghost proposal. // Refuse rather than assume Ctrl-U has the same meaning in every shell/TUI. - if self.human_input.pending || self.input.has_pending() { return Err(SessionError::InputPending); } + if self.cancelled_input || self.human_input.pending || self.input.has_pending() { return Err(SessionError::InputPending); } self.mode = mode; match mode { AgentMode::Observe => { @@ -1562,25 +1587,27 @@ impl Session { } // Attention changes can impose a Co-pilot cap without an explicit mode // switch. Never append a proposal to pre-existing physical shell input. - if self.human_input.pending || self.input.has_pending() { return Err(SessionError::InputPending); } + if self.cancelled_input || self.human_input.pending || self.input.has_pending() { return Err(SessionError::InputPending); } let cmd = p.text.trim().to_string(); - let col = self.screen.cursor().col; - self.input.feed(p.text.as_bytes(), col); - self.write_pty(p.text.as_bytes()); let decision = self.analyse(&cmd).decision; + if !matches!(decision, PolicyDecision::Deny { .. }) { + let col = self.screen.cursor().col; + self.human_input.remember_empty(&self.screen); + self.input.feed(p.text.as_bytes(), col); + self.write_pty(p.text.as_bytes())?; + } let (state, result, policy) = match decision { PolicyDecision::Deny { label } => { - self.write_pty(b"\x15"); - self.input.reset(); + self.cancel_typed_input(true); (ProposalState::Denied, KeyResult::Denied { cmd: cmd.clone(), label: label.clone() }, format!("deny:{label}")) } PolicyDecision::Allow => { - self.write_pty(b"\r"); + self.write_pty(b"\r")?; self.input.reset(); (ProposalState::Executed, KeyResult::Executed { cmd: cmd.clone() }, "allow".into()) } PolicyDecision::Confirm { label } => { - self.write_pty(b"\r"); + self.write_pty(b"\r")?; self.input.reset(); (ProposalState::Executed, KeyResult::Executed { cmd: cmd.clone() }, format!("confirm:{label}")) } @@ -1686,6 +1713,7 @@ impl Session { return Err(SessionError::InvalidInput("text must not contain control characters".into())); } self.check_writer(conn, Affordance::Type)?; + if self.cancelled_input { return Err(SessionError::InputUnverified); } if self.human_input.pending { return Err(SessionError::InputPending); } if self.effective_mode() == AgentMode::Copilot { self.proposal_mut(conn).text.push_str(text); @@ -1697,7 +1725,7 @@ impl Session { let col = self.screen.cursor().col; if !self.input.has_pending() { self.human_input.remember_empty(&self.screen); } self.input.feed(text.as_bytes(), col); - self.write_pty(text.as_bytes()); + self.write_pty(text.as_bytes())?; self.note_agent_write(conn, text.len()); Ok(()) } @@ -1714,6 +1742,7 @@ impl Session { return self.agent_interrupt(conn).map(|_| KeyResult::Sent); } self.check_writer(conn, Affordance::SendKey)?; + if self.cancelled_input { return Err(SessionError::InputUnverified); } if self.human_input.pending { return Err(SessionError::InputPending); } if self.effective_mode() == AgentMode::Observe { return Err(SessionError::WrongMode(AgentMode::Observe)); @@ -1747,7 +1776,7 @@ impl Session { if !key.eq_ignore_ascii_case("ENTER") { let col = self.screen.cursor().col; self.input.feed(bytes, col); - self.write_pty(bytes); + self.write_pty(bytes)?; self.note_agent_write(conn, bytes.len()); return Ok(KeyResult::Sent); } @@ -1760,15 +1789,8 @@ impl Session { return Err(SessionError::InvalidInput("the cursor line contains concealed text; only the human can submit it".into())); } let agent_id = self.agent_name(conn); - // The cursor row is only a visual fragment when a command wraps (or - // scrolls beyond the screen). Keep the full tracked input authoritative - // unless completion/history/cursor editing made it unreliable. - let cmd = input::resolve_command( - &self.input.line(), - self.input.is_dirty(), - &self.screen.cursor_line(), - self.input.prompt_col(), - ); + let cmd = input::resolve_command(&self.input.line(), self.input.is_dirty()) + .ok_or(SessionError::InputUnverified)?; self.last_agent_write = Some(Instant::now()); let analysis = self.analyse(&cmd); match analysis.decision.clone() { @@ -1797,12 +1819,11 @@ impl Session { } return Ok(KeyResult::Scheduled { exec_id, cmd, grace_ms }); } - self.execute_with_source(&agent_id, &cmd, intent, None); + self.execute_with_source(&agent_id, &cmd, intent, None)?; Ok(KeyResult::Executed { cmd }) } PolicyDecision::Deny { label } => { - self.write_pty(b"\x15"); - self.input.reset(); + self.cancel_typed_input(true); self.last_agent_cmd = Some(cmd.clone()); self.audit.record(&agent_id, "exec", json!({ "cmd": cmd, "policy": "deny", "label": label, "intent": intent, "isolation": analysis.isolation_violation })); self.broadcast(ServerEvent::AgentExec { agent_id, cmd: cmd.clone(), policy: format!("deny:{label}"), intent, submission_id: None }); @@ -1830,13 +1851,14 @@ impl Session { } } - fn execute_with_source(&mut self, agent_id: &str, cmd: &str, intent: Option, by: Option<&str>) { + fn execute_with_source(&mut self, agent_id: &str, cmd: &str, intent: Option, by: Option<&str>) -> Result<(), SessionError> { + self.write_pty(b"\r")?; let submission_id = self.shell_submission(agent_id, cmd); self.last_agent_cmd = Some(cmd.to_string()); - self.write_pty(b"\r"); self.input.reset(); self.audit.record(agent_id, "exec", json!({ "cmd": cmd, "policy": "allow", "intent": intent, "by": by, "submissionId": submission_id })); self.broadcast(ServerEvent::AgentExec { agent_id: agent_id.to_string(), cmd: cmd.to_string(), policy: "allow".into(), intent, submission_id }); + Ok(()) } fn cancel_scheduled_if(&mut self, pred: impl Fn(&ScheduledExec) -> bool, reason: &str) { @@ -1846,6 +1868,7 @@ impl Session { s.cancel_reason = Some(reason.to_string()); let (id, agent, cmd) = (s.exec_id.clone(), s.agent_id.clone(), s.cmd.clone()); // Cancellation leaves bytes in the shell, so retain their tracking. + self.cancelled_input = self.input.has_pending(); self.audit.record(&agent, "exec_cancelled", json!({ "exec": id, "cmd": cmd, "reason": reason })); self.broadcast(ServerEvent::ExecCancelled { exec_id: id, reason: reason.into() }); } @@ -1861,7 +1884,11 @@ impl Session { let (agent, cmd, intent) = (s.agent_id.clone(), s.cmd.clone(), s.intent.clone()); self.audit.record("human", "exec_cosigned", json!({ "exec": exec_id, "cmd": cmd, "agentId": agent })); self.broadcast(ServerEvent::ExecCosigned { exec_id: exec_id.into(), agent_id: agent.clone() }); - self.execute_with_source(&agent, &cmd, intent, Some("human_cosign")); + if let Err(error) = self.execute_with_source(&agent, &cmd, intent, Some("human_cosign")) { + if let Some(s) = &mut self.scheduled { s.state = ExecState::Cancelled; s.cancel_reason = Some("input_outcome_unknown".into()); } + self.broadcast(ServerEvent::ExecCancelled { exec_id: exec_id.into(), reason: "input_outcome_unknown".into() }); + return Err(error); + } Ok(()) } Some(s) if s.exec_id == exec_id => Err(SessionError::InvalidInput(format!("{exec_id} is not scheduled"))), @@ -1870,7 +1897,8 @@ impl Session { } /// Frontend/human: cancel a scheduled execution during its grace window. - /// The typed line is left for the human to inspect; Ctrl-U it if unwanted. + /// The typed line remains visible; require explicit recovery before an agent + /// appends again, without guessing clearing keys for a foreground program. pub fn cancel_exec(&mut self, exec_id: &str) -> Result<(), SessionError> { match &self.scheduled { Some(s) if s.exec_id == exec_id && s.state == ExecState::Scheduled => { @@ -1890,6 +1918,16 @@ impl Session { self.scheduled.as_ref().filter(|s| s.state == ExecState::Scheduled) } + fn cancel_typed_input(&mut self, automatic: bool) { + if !self.input.has_pending() { return; } + self.cancelled_input = true; + if automatic && self.shell_prompt_confirmed && !self.shell_submission_inflight + && self.shell_foreground_sequence.is_none() && !self.screen.alternate_screen() { + self.human_input.begin_cleanup(&self.screen); + if self.write_pty(b"\x03").is_ok() { self.input.reset(); } + } + } + /// Ctrl-C. Cancels any pending approval / scheduled execution from this agent. pub fn agent_interrupt(&mut self, conn: ConnId) -> Result<(), SessionError> { self.require_participant(conn)?; @@ -1904,9 +1942,12 @@ impl Session { self.finish_approval(&id, ApprovalState::Denied, "interrupted"); } self.reject_proposal_if(|p| p.conn == conn, "interrupted"); - self.write_pty(b"\x03"); + let clearing = self.input.has_pending() || self.cancelled_input; + if clearing { self.human_input.begin_cleanup(&self.screen); } + self.write_pty(b"\x03")?; self.input.reset(); - self.human_input.reset(); + self.cancelled_input = clearing; + if !clearing { self.human_input.reset(); } let name = self.agent_name(conn); self.audit.record(&name, "interrupt", json!({})); self.note_agent_write(conn, 1); @@ -1930,7 +1971,7 @@ impl Session { // polling for a decision counts as activity self.authority.renew(a.conn, Instant::now()); } - Ok(ApprovalInfo { approval_id: a.id.clone(), state: a.state, cmd: a.cmd.clone(), label: a.label.clone() }) + Ok(ApprovalInfo { input_retained: self.cancelled_input && self.input.has_pending(), approval_id: a.id.clone(), state: a.state, cmd: a.cmd.clone(), label: a.label.clone() }) } // ----- approval ------------------------------------------------------- @@ -1944,6 +1985,7 @@ impl Session { return Err(SessionError::InvalidInput("review_scope_unavailable: this request cannot allow a command category for the session".into())); } if decision != ApprovalDecision::Deny { + if self.cancelled_input { return Err(SessionError::InputUnverified); } self.require_shared()?; if request.review.remote != self.remote_policy() || request.review.profile_revision != self.profile_revision @@ -1963,13 +2005,13 @@ impl Session { }; let req = self .finish_approval(id, state, by) - .ok_or_else(|| SessionError::InvalidInput(format!("{id} is not pending")))?; + .ok_or_else(|| if self.cancelled_input { SessionError::InputDeliveryUnknown } else { SessionError::InvalidInput(format!("{id} is not pending")) })?; if decision == ApprovalDecision::AllowSession { self.policy.allow_for_session(&req.label); self.audit.record("human", "session_allow", json!({ "label": req.label })); self.broadcast(ServerEvent::SessionAllowsChanged { allows: self.policy.session_allows() }); } - Ok(ApprovalInfo { approval_id: req.id, state: req.state, cmd: req.cmd, label: req.label }) + Ok(ApprovalInfo { input_retained: self.cancelled_input && self.input.has_pending(), approval_id: req.id, state: req.state, cmd: req.cmd, label: req.label }) } /// Resolve the oldest pending approval. @@ -1983,23 +2025,20 @@ impl Session { } fn finish_approval(&mut self, id: &str, state: ApprovalState, by: &str) -> Option { - let req = self.approvals.resolve(id, state)?; + if self.approvals.get(id)?.state != ApprovalState::Pending { return None; } let outcome = match state { ApprovalState::Granted => { - self.write_pty(b"\r"); + if self.write_pty(b"\r").is_err() { return None; } + self.input.reset(); "granted" } - ApprovalState::Denied => { - self.write_pty(b"\x15"); - "denied" - } - ApprovalState::Expired => { - self.write_pty(b"\x15"); - "expired" + ApprovalState::Denied | ApprovalState::Expired => { + self.cancel_typed_input(by != "interrupted" && by != "human_input"); + if state == ApprovalState::Denied { "denied" } else { "expired" } } ApprovalState::Pending => unreachable!(), }; - self.input.reset(); + let req = self.approvals.resolve(id, state)?; let submission_id = if state == ApprovalState::Granted { self.shell_submission(&req.agent_id, &req.cmd) } else { None }; self.audit.record( &req.agent_id, @@ -2051,8 +2090,11 @@ impl Session { if let Some(s) = &mut self.scheduled { if surface_ready && s.state == ExecState::Scheduled && s.due <= now { s.state = ExecState::Executed; - let (agent, cmd, intent) = (s.agent_id.clone(), s.cmd.clone(), s.intent.clone()); - self.execute_with_source(&agent, &cmd, intent, None); + let (agent, cmd, intent, exec_id) = (s.agent_id.clone(), s.cmd.clone(), s.intent.clone(), s.exec_id.clone()); + if self.execute_with_source(&agent, &cmd, intent, None).is_err() { + if let Some(s) = &mut self.scheduled { s.state = ExecState::Cancelled; s.cancel_reason = Some("input_outcome_unknown".into()); } + self.broadcast(ServerEvent::ExecCancelled { exec_id, reason: "input_outcome_unknown".into() }); + } } } if self.screen_dirty { @@ -2084,7 +2126,7 @@ impl Session { output_seq: self.output_seq, surface_available: self.participation.shared, completion_prompt_ready: self.completion_prompt_ready(), - input_pending: self.human_input.pending || self.input.has_pending(), + input_pending: self.cancelled_input || self.human_input.pending || self.input.has_pending(), external_input_available: self.external_writer_active(), profile_id: self.execution_profile.as_ref().map(|p|p.id.clone()), profile_name: self.execution_profile.as_ref().map(|p|p.name.clone()), @@ -2209,12 +2251,12 @@ mod ssh_policy_tests { #[test] fn delayed_prompt_cannot_certify_a_new_submission_or_foreground() { let mut s = session(); - s.human_input(b"\r"); - s.human_input(b"sleep 10\r"); + s.human_input(b"\r").unwrap(); + s.human_input(b"sleep 10\r").unwrap(); s.shell_event(2, "prompt", "", "", ""); assert!(s.review_required(), "old empty-enter prompt does not certify a new command"); s.shell_event(3, "start", "sleep 10", "", ""); - s.human_input(b"\x03"); + s.human_input(b"\x03").unwrap(); s.shell_event(4, "prompt", "", "", ""); assert!(s.review_required(), "foreground cancellation still needs its matching end"); s.shell_event(5, "end", "99", "", "130"); @@ -2227,7 +2269,7 @@ mod ssh_policy_tests { fn idle_prompt_recovery_does_not_end_remote_ssh() { let mut s = session(); s.shell_event(2, "start", "ssh host", "", ""); - s.human_input(b"\x03"); + s.human_input(b"\x03").unwrap(); s.shell_event(3, "prompt", "", "", ""); assert!(s.remote_policy()); s.shell_event(4, "end", "2", "", "0"); diff --git a/crates/core/src/session/participation.rs b/crates/core/src/session/participation.rs index 3e43c4a..23d009c 100644 --- a/crates/core/src/session/participation.rs +++ b/crates/core/src/session/participation.rs @@ -51,7 +51,7 @@ impl Session { { return Ok(()); } - if shared && (self.input.has_pending() || self.human_input.pending) { + if shared && (self.cancelled_input || self.input.has_pending() || self.human_input.pending) { return Err(SessionError::InputPending); } Ok(()) diff --git a/crates/core/tests/attention.rs b/crates/core/tests/attention.rs index aa1f441..b3deb81 100644 --- a/crates/core/tests/attention.rs +++ b/crates/core/tests/attention.rs @@ -24,7 +24,7 @@ fn switching_tabs_preserves_shared_session_and_human_input_preempts() { assert!(h.session.current_lease().is_some()); assert!(names(&fe.lock().unwrap()).contains(&"attention_changed".to_string())); h.session.agent_request_attention(1,Some("check result".into())).unwrap(); - h.session.human_input(b"\x03"); + h.session.human_input(b"\x03").unwrap(); assert!(h.session.current_lease().is_none());assert!(h.session.agent_type(1,"late").is_err()); assert_eq!(h.pty_str(),"ls -l\x03"); h.session.set_attended(true);assert!(h.session.status().attention_request.is_none()); @@ -56,7 +56,7 @@ fn unattended_default_is_safe_even_for_the_human_cli_path() { let mut h = Harness::with(&Harness::test_policy(), Duration::from_secs(60), Duration::from_secs(300)); h.session.set_attended(false); // human actions are never blocked by attention - h.session.human_input(b"echo human\r"); + h.session.human_input(b"echo human\r").unwrap(); assert_eq!(h.pty_str(), "echo human\r"); assert!(h.session.affordances(Actor::Human).contains(&Affordance::Snapshot)); } @@ -74,6 +74,8 @@ fn grace_in_a_tab_nobody_watches_knocks_for_attention() { let KeyResult::Scheduled { exec_id, .. } = h.session.agent_send_key(1, "ENTER").unwrap() else { panic!() }; assert!(!names(&events.lock().unwrap()).contains(&"attention_requested".to_string())); h.session.cancel_exec(&exec_id).unwrap(); + assert!(matches!(h.session.agent_type(1, "echo background"), Err(conn_core::session::SessionError::InputUnverified))); + h.session.human_input(b"\x03").unwrap(); // Unwatched tab: grace is the human's chance to object, so the tab must knock. h.session.set_attended(false); h.session.pty_output(b"\r\x1b[2K$ "); diff --git a/crates/core/tests/command_tracking.rs b/crates/core/tests/command_tracking.rs index 285d314..8dcd954 100644 --- a/crates/core/tests/command_tracking.rs +++ b/crates/core/tests/command_tracking.rs @@ -11,7 +11,7 @@ fn wrapped_unicode_redirect_is_reviewed_and_audited_in_full() { for cols in [20, 80, 132] { let mut h = Harness::new(); let events = h.frontend("ui"); - h.session.resize(4, cols); + h.session.resize(4, cols).unwrap(); h.agent(1, "agent"); h.session.agent_request_control(1).unwrap(); h.session.pty_output("사용자@conn$ ".as_bytes()); @@ -44,7 +44,7 @@ fn wrapped_unicode_redirect_is_reviewed_and_audited_in_full() { fn wrapping_cannot_hide_a_dangerous_prefix_from_policy() { let command = format!("rm -- '{}' && printf '%s' 'done'", "example-".repeat(40)); let mut h = Harness::new(); - h.session.resize(3, 40); + h.session.resize(3, 40).unwrap(); h.agent(1, "agent"); h.session.agent_request_control(1).unwrap(); h.session.pty_output(b"dev$ "); diff --git a/crates/core/tests/control_regressions.rs b/crates/core/tests/control_regressions.rs index 9f50cab..adba000 100644 --- a/crates/core/tests/control_regressions.rs +++ b/crates/core/tests/control_regressions.rs @@ -14,10 +14,12 @@ fn mode_switch_refuses_physical_input_until_cancelled() { h.agent(1, "claude"); h.session.agent_request_control(1).unwrap(); h.session.agent_type(1, "echo mode-switch-test").unwrap(); + h.session.pty_output(b"echo mode-switch-test"); assert!(matches!(h.session.set_mode(AgentMode::Copilot), Err(SessionError::InputPending))); assert_eq!(h.session.mode(), AgentMode::Autopilot); assert_eq!(h.pty_str(), "echo mode-switch-test", "rejected switch neither types nor executes"); h.session.agent_interrupt(1).unwrap(); + common::present(&mut h.session, vec![]); h.session.set_mode(AgentMode::Copilot).unwrap(); h.session.agent_type(1, "echo new-proposal").unwrap(); let KeyResult::Proposed { proposal_id, .. } = h.session.agent_send_key(1, "ENTER").unwrap() else { panic!() }; @@ -37,7 +39,7 @@ fn cancelled_grace_retains_tracking_and_cannot_hide_pending_input() { h.session.cancel_exec(&exec_id).unwrap(); assert!(matches!(h.session.set_mode(AgentMode::Copilot), Err(SessionError::InputPending))); // Explicit cancel; a Ctrl-U keystroke alone cannot prove line erasure. - h.session.human_input(b"\x03"); + h.session.human_input(b"\x03").unwrap(); h.session.set_mode(AgentMode::Copilot).unwrap(); } @@ -100,7 +102,7 @@ fn revoked_sharing_cannot_append_to_existing_shell_input() { assert!(h.session.agent_type(1,"echo proposed").is_err()); assert!(h.session.agent_interrupt(1).is_err()); h.session.set_attended(true);common::present(&mut h.session,vec!["echo stale".into()]); - h.session.human_input(b"\x15"); + h.session.human_input(b"\x15").unwrap(); assert_eq!(h.pty_str(),"echo stale\x15"); } @@ -237,26 +239,26 @@ fn real_pty_copilot_interrupt_stops_running_shell_command() { // A terminal's signal handling may flush bytes queued with Ctrl-C. Wait for // the shell's fresh prompt before sending the human's follow-up command. wait_for(|| session.lock().screen().cursor_line().contains("conn-interrupt$")); - session.lock().human_input(b"printf 'interrupt-result-%s\\n' $((40+2))\r"); + session.lock().human_input(b"printf 'interrupt-result-%s\\n' $((40+2))\r").unwrap(); wait_for(|| session.lock().screen().rows().iter().any(|r| r.trim() == "interrupt-result-42")); engine.terminate().unwrap(); } #[test] -fn human_input_denies_a_pending_approval_instead_of_editing_its_command() { +fn human_input_denies_pending_review_and_preserves_unknown_foreground_editing() { let mut h = Harness::new(); h.agent(1, "a"); h.session.agent_request_control(1).unwrap(); h.session.agent_type(1, "sudo ls").unwrap(); let KeyResult::Pending { approval_id, .. } = h.session.agent_send_key(1, "ENTER").unwrap() else { panic!("expected an approval") }; - h.session.human_input(b"x"); - // The reviewed text is gone before the human's byte lands: clear line, then "x". - assert_eq!(h.pty_str(), "sudo ls\x15x"); + h.session.human_input(b"x").unwrap(); + // Unknown foreground input belongs to the human; do not inject a guessed clear key. + assert_eq!(h.pty_str(), "sudo lsx"); assert!(h.session.status().pending.is_empty(), "the approval must not outlive human input"); assert_eq!(h.session.check_approval(&approval_id).unwrap().state, ApprovalState::Denied); // Approving late can no longer submit a line that was never reviewed. assert!(h.session.resolve_approval(&approval_id, Decision::Grant, "human").is_err()); - assert_eq!(h.pty_str(), "sudo ls\x15x", "nothing is submitted by a late approval"); + assert_eq!(h.pty_str(), "sudo lsx", "nothing is submitted by a late approval"); } #[test] diff --git a/crates/core/tests/edited_review.rs b/crates/core/tests/edited_review.rs new file mode 100644 index 0000000..3fead1f --- /dev/null +++ b/crates/core/tests/edited_review.rs @@ -0,0 +1,151 @@ +//! Real shell semantics: no synthetic replacement of the terminal screen. +#![cfg(unix)] +mod common; +use common::SessionExt; +use conn_core::{ + approval::Decision, + backend::Profile, + session::{ConnKind, KeyResult, ServerEvent, SessionError}, + Engine, EngineConfig, +}; +use std::time::{Duration, Instant}; + +#[track_caller] +fn wait(mut predicate: impl FnMut() -> bool) { + let deadline = Instant::now() + Duration::from_secs(8); + while !predicate() { + assert!(Instant::now() < deadline, "real shell timed out"); + std::thread::sleep(Duration::from_millis(10)); + } +} + +#[test] +fn real_bash_edited_wrapped_and_wide_prompt_commands_keep_full_review_identity() { + for prompt in [ + r"\[\e[31m\][DEV]\[\e[0m\]fixture$ ", + r"\[\e[31m\][DEV]\[\e[0m\]한글$ ", + ] { + let dir = tempfile::tempdir().unwrap(); + let rc = dir.path().join("inputrc"); + std::fs::write( + &rc, + "set editing-mode emacs\nset input-meta on\nset output-meta on\nset convert-meta off\n", + ) + .unwrap(); + let mut profile = Profile::local("review-test".into(), "/bin/bash".into()); + profile.args = vec!["--noprofile".into(), "--norc".into(), "-i".into()]; + profile.env.insert("PS1".into(), prompt.into()); + profile + .env + .insert("INPUTRC".into(), rc.to_string_lossy().into_owned()); + profile.env.insert("HISTFILE".into(), "/dev/null".into()); + profile.env.insert( + "LC_ALL".into(), + if cfg!(target_os = "macos") { + "en_US.UTF-8" + } else { + "C.UTF-8" + } + .into(), + ); + let engine = Engine::spawn(EngineConfig { + profile: Some(profile), + cwd: Some(dir.path().into()), + rows: 24, + cols: 80, + audit: Some(conn_core::audit::Audit::null()), + ..Default::default() + }) + .unwrap(); + let session = engine.session(); + wait(|| session.lock().screen().cursor_line().contains('$')); + wait(|| session.lock().status().shell_integration.state == "active"); + { + let mut s = session.lock(); + s.register_conn(1, ConnKind::Agent, "fixture", Box::new(|_: ServerEvent| {})); + s.agent_request_control(1).unwrap(); + } + let text = format!("WRAP_BEGIN_{}_END", "x".repeat(220)); + let cmd = format!("printf '%s' > result '{text}'"); + session.lock().agent_type(1, &cmd).unwrap(); + wait(|| session.lock().screen().rows().join("").contains("_END'")); + let id = { + let mut s = session.lock(); + s.agent_send_key(1, "LEFT").unwrap(); + let KeyResult::Pending { + approval_id, + cmd: reviewed, + .. + } = s + .agent_send_key_with(1, "ENTER", Some("Write a disposable fixture file".into())) + .unwrap() + else { + panic!("edited redirect bypassed review") + }; + assert_eq!(reviewed, cmd); + approval_id + }; + assert!(!dir.path().join("result").exists()); + session + .lock() + .resolve_approval(&id, Decision::Grant, "test") + .unwrap(); + wait(|| std::fs::read_to_string(dir.path().join("result")).is_ok_and(|v| v == text)); + wait(|| session.lock().screen().cursor_line().ends_with("$")); + wait(|| !session.lock().review_required()); + // A middle-of-line denial must not splice the remaining suffix into the + // next command. The known idle Bash prompt permits automatic cleanup. + let next = "printf SAFE > untouched"; + session.lock().agent_type(1, next).unwrap(); + wait(|| session.lock().screen().cursor_line().contains("untouched")); + let id = { + let mut s = session.lock(); + for _ in 0.."untouched".len() { + s.agent_send_key(1, "LEFT").unwrap(); + } + let KeyResult::Pending { approval_id, .. } = s + .agent_send_key_with(1, "ENTER", Some("Review only".into())) + .unwrap() + else { + panic!() + }; + approval_id + }; + session + .lock() + .resolve_approval(&id, Decision::Deny, "test") + .unwrap(); + wait(|| !session.lock().status().input_pending); + wait(|| session.lock().screen().cursor_line().ends_with('$')); + wait(|| !session.lock().review_required()); + session.lock().agent_type(1, "printf NEXT_OK").unwrap(); + assert!(matches!( + session + .lock() + .agent_send_key_with(1, "ENTER", Some("Print a fixture marker".into())) + .unwrap(), + KeyResult::Executed { .. } + )); + wait(|| { + session + .lock() + .screen() + .rows() + .iter() + .any(|r| r.starts_with("NEXT_OK")) + }); + assert!(!dir.path().join("untouched").exists()); + wait(|| session.lock().screen().cursor_line().ends_with('$')); + wait(|| !session.lock().review_required()); + session.lock().agent_send_key(1, "UP").unwrap(); + assert!(matches!( + session.lock().agent_send_key_with( + 1, + "ENTER", + Some("Never submit unknown history".into()) + ), + Err(SessionError::InputUnverified) + )); + engine.terminate().unwrap(); + } +} diff --git a/crates/core/tests/external_private.rs b/crates/core/tests/external_private.rs index 25e4fd1..8796357 100644 --- a/crates/core/tests/external_private.rs +++ b/crates/core/tests/external_private.rs @@ -38,7 +38,7 @@ fn private_input_is_untracked_and_human_takeover_permanently_revokes_external_wr s.set_trace(trace.clone()); s.write_external(b"external-fixture").unwrap(); assert!(s.write_external(&[b'x'; 1025]).is_err()); - s.human_input(b"human-fixture\r"); + s.human_input(b"human-fixture\r").unwrap(); assert!(!s.external_writer_active()); assert!(s.write_external(b"must-not-arrive").is_err()); assert_eq!(&*bytes.lock().unwrap(), b"external-fixturehuman-fixture\r"); @@ -71,7 +71,7 @@ fn terminal_protocol_responses_do_not_impersonate_human_takeover_or_restore_acce s.write_terminal_response(b"\x1b[1;1R").unwrap(); assert!(s.external_writer_active()); assert!(s.input_line().is_empty()); - s.human_input(b"x"); + s.human_input(b"x").unwrap(); assert!(!s.external_writer_active()); s.write_terminal_response(b"\x1b[0n").unwrap(); assert!(!s.external_writer_active()); @@ -192,7 +192,7 @@ fn direct_startup_preserves_argv_cwd_and_env_without_logging_hidden_input_or_sta } let events = Arc::new(Mutex::new(Vec::new())); engine.subscribe("native", Box::new(VecSink(events.clone()))); - engine.write_input(b"synthetic-hidden-value\r"); + engine.write_input(b"synthetic-hidden-value\r").unwrap(); let until = Instant::now() + Duration::from_secs(5); while !engine.has_exited() { assert!(Instant::now() < until, "startup did not exit"); diff --git a/crates/core/tests/frontend.rs b/crates/core/tests/frontend.rs index 8dd6adb..d514961 100644 --- a/crates/core/tests/frontend.rs +++ b/crates/core/tests/frontend.rs @@ -24,7 +24,7 @@ fn frontend_receives_lifecycle_events() { h.session.agent_type(1, "kubectl delete pod x").unwrap(); let KeyResult::Pending { approval_id, .. } = h.session.agent_send_key(1, "ENTER").unwrap() else { panic!() }; h.session.resolve_approval(&approval_id, Decision::Grant, "frontend").unwrap(); - h.session.human_input(b"x"); + h.session.human_input(b"x").unwrap(); h.session.pty_output(b"out"); common::present(&mut h.session, vec!["out".into()]); h.session.tick(Instant::now()); @@ -40,7 +40,7 @@ fn frontend_receives_lifecycle_events() { } #[test] -fn human_input_denies_a_pending_approval_and_clears_the_typed_line() { +fn human_input_denies_review_and_takes_over_the_visible_line() { let mut h = Harness::new(); h.agent(1, "copilot"); h.session.agent_request_control(1).unwrap(); @@ -49,10 +49,10 @@ fn human_input_denies_a_pending_approval_and_clears_the_typed_line() { // Human keystrokes are a takeover, never a prompt answer: "y" must not approve. // The pending command is already typed on the line, so the approval cannot stay // alive either, or a later approve would submit that line plus the human's bytes. - h.session.human_input(b"y"); + h.session.human_input(b"y").unwrap(); assert!(h.session.controller().is_human()); assert!(h.session.status().pending.is_empty(), "the approval is denied, not answered and not left pending"); - assert_eq!(h.pty_str(), "sudo ls\x15y", "the reviewed line is cleared before the human's byte; nothing is submitted"); + assert_eq!(h.pty_str(), "sudo lsy", "human editing takes over the visible line; no guessed clear key or submission"); assert!(h.session.resolve_first_pending(Decision::Grant, "frontend").is_err()); } @@ -88,7 +88,7 @@ fn human_input_cancels_grace_window() { h.session.agent_type(1, "echo hi").unwrap(); let KeyResult::Scheduled { exec_id, .. } = h.session.agent_send_key(1, "ENTER").unwrap() else { panic!() }; h.session.pty_output(b"echo hi"); - h.session.human_input(b"\x15"); + h.session.human_input(b"\x15").unwrap(); h.session.pty_output(b"\r\x1b[K"); let (state, reason) = h.session.exec_state(&exec_id).unwrap(); assert_eq!(state, ExecState::Cancelled); diff --git a/crates/core/tests/guard.rs b/crates/core/tests/guard.rs index d497852..ba2d60a 100644 --- a/crates/core/tests/guard.rs +++ b/crates/core/tests/guard.rs @@ -60,7 +60,7 @@ fn dangerous_segment_must_be_alone() { let KeyResult::Denied { label, .. } = r else { panic!("{r:?}") }; assert_eq!(label, "run dangerous commands alone"); assert!(events.lock().unwrap().iter().any(|event| matches!(event, conn_core::session::ServerEvent::AgentExec {policy, ..} if policy == "deny:run dangerous commands alone"))); - assert!(h.pty_str().ends_with("\x15"), "line cleared"); + assert!(h.session.status().input_pending, "unverified foreground input is retained for explicit recovery"); assert!(h.session.status().pending.is_empty(), "no approval was offered"); // the analysis still shows what it would have deleted let a = h.session.analyse_line("cd .. && rm -rf -- hello-mel"); @@ -70,10 +70,9 @@ fn dangerous_segment_must_be_alone() { #[test] fn evasions_normalise_to_the_real_command() { - let mut h = strict(); - h.agent(1, "claude"); - h.session.agent_request_control(1).unwrap(); for line in ["\\rm -rf x", "/bin/rm -rf x", "command rm -rf x", "r''m -rf x", "sudo rm -rf x", "env A=1 rm -rf x"] { + // Each policy case is independent of cancellation/recovery semantics. + let mut h = strict(); h.agent(1, "claude"); h.session.agent_request_control(1).unwrap(); common::present(&mut h.session, vec!["$ ".into()]); h.session.agent_type(1, line).unwrap(); let r = h.session.agent_send_key_with(1, "ENTER", Some("t".into())).unwrap(); @@ -93,6 +92,8 @@ fn opaque_constructs_ask_by_default() { h.agent(1, "claude"); h.session.agent_request_control(1).unwrap(); for line in ["sh -c 'ls'", "eval \"$CMD\"", "find . -name '*.log' -delete", "python3 -c 'print(1)'", "echo $(whoami)"] { + // Each policy case is independent of cancellation/recovery semantics. + let mut h = strict(); h.agent(1, "claude"); h.session.agent_request_control(1).unwrap(); common::present(&mut h.session, vec!["$ ".into()]); h.session.agent_type(1, line).unwrap(); let r = h.session.agent_send_key_with(1, "ENTER", Some("t".into())).unwrap(); diff --git a/crates/core/tests/handoff.rs b/crates/core/tests/handoff.rs index 31d84db..25b339f 100644 --- a/crates/core/tests/handoff.rs +++ b/crates/core/tests/handoff.rs @@ -58,7 +58,7 @@ fn copilot_confirm_is_granted_by_commit_but_deny_still_blocks() { h.session.agent_type(1, "rm -rf /").unwrap(); let KeyResult::Proposed { proposal_id, .. } = h.session.agent_send_key(1, "ENTER").unwrap() else { panic!() }; assert!(matches!(h.session.accept_proposal(&proposal_id).unwrap(), KeyResult::Denied { .. })); - assert_eq!(h.pty_str(), "rm -rf /\x15"); + assert_eq!(h.pty_str(), "", "a denied proposal is never typed"); } #[test] @@ -77,7 +77,7 @@ fn proposal_is_rejected_by_human_typing_or_esc() { h.session.agent_request_control(1).unwrap(); h.session.agent_type(1, "pwd").unwrap(); let id = h.session.proposal().unwrap().proposal_id.clone(); - h.session.human_input(b"e"); + h.session.human_input(b"e").unwrap(); assert_eq!(h.session.proposal_state(&id), Some(ProposalState::Rejected)); assert!(h.session.controller().is_human()); assert_eq!(h.pty_str(), "e"); @@ -141,7 +141,7 @@ fn hand_back_returns_control_to_last_agent() { h.session.agent_request_control(1).unwrap(); h.session.agent_type(1, "echo hi").unwrap(); h.session.agent_send_key(1, "ENTER").unwrap(); - h.session.human_input(b"x"); + h.session.human_input(b"x").unwrap(); assert!(h.session.controller().is_human()); let st = h.session.status(); assert_eq!(st.last_agent.as_ref().unwrap().last_cmd.as_deref(), Some("echo hi")); diff --git a/crates/core/tests/input.rs b/crates/core/tests/input.rs index 4d6f3fc..44923c8 100644 --- a/crates/core/tests/input.rs +++ b/crates/core/tests/input.rs @@ -53,14 +53,34 @@ fn utf8_and_split_sequences() { #[test] fn resolve_prefers_tracked_when_clean() { - assert_eq!(resolve_command("ls", false, "$ l", Some(2)), "ls"); + assert_eq!(resolve_command("ls", false), Some("ls".into())); } #[test] -fn resolve_uses_vt_when_dirty() { - assert_eq!(resolve_command("ls sr", true, "$ ls src/", Some(2)), "ls src/"); - // VT yields nothing → fall back to tracked - assert_eq!(resolve_command("ls sr", true, "", Some(2)), "ls sr"); - // no prompt col, tracked appears at end of line - assert_eq!(resolve_command("ls", true, "dev$ ls", None), "ls"); +fn uncertain_edits_never_resolve_from_screen_fragments_or_old_text() { + assert_eq!(resolve_command("ls sr", true), None); + let mut t=InputTracker::new(); + t.feed(b"echo old\t", 2); t.feed(b"\x15echo new",2); + assert!(t.is_dirty(), "a guessed erase cannot restore command provenance"); +} + +#[test] +fn middle_edits_preserve_the_complete_command_and_suffix() { + let mut t=InputTracker::new(); + t.feed(b"printf AB > result", 9); + for _ in 0..9 { t.feed(b"\x1b[D", 0); } + t.feed(b"X\x7fC",0); + assert_eq!(t.line(), "printf ABC > result"); + assert!(!t.is_dirty()); + t.feed(b"\x15",0); + assert_eq!(t.line()," > result", "Ctrl-U preserves the suffix"); +} + +#[test] +fn non_ascii_cursor_edits_require_recovery_but_motion_preserves_text() { + let mut t=InputTracker::new(); t.feed("echo é".as_bytes(),0); + t.feed(b"\x1b[D",0); assert!(!t.is_dirty()); + t.feed(b"X",0); assert!(t.is_dirty()); + let mut t=InputTracker::new(); t.feed("echo é".as_bytes(),0); + t.feed(b"\x7f",0); assert!(t.is_dirty(), "Unicode deletion cannot certify a scalar-count assumption"); } diff --git a/crates/core/tests/input_delivery.rs b/crates/core/tests/input_delivery.rs new file mode 100644 index 0000000..605f3b2 --- /dev/null +++ b/crates/core/tests/input_delivery.rs @@ -0,0 +1,155 @@ +mod common; +use common::SessionExt; +use conn_core::{ + approval::Decision, + audit::Audit, + policy::{Policy, PolicyStore}, + session::{ConnKind, KeyResult, ServerEvent, Session, SessionConfig, SessionError}, + Pacing, +}; +use portable_pty::{MasterPty, PtySize}; +use std::{ + io::{self, Read, Write}, + sync::{Arc, Mutex}, +}; + +#[derive(Default)] +struct State { + bytes: Vec, + remaining: Option, + fail_flush: bool, +} +struct Writer(Arc>); +impl Write for Writer { + fn write(&mut self, bytes: &[u8]) -> io::Result { + let mut s = self.0.lock().unwrap(); + if s.remaining == Some(0) { + return Err(io::Error::new(io::ErrorKind::BrokenPipe, "fixture")); + } + let n = s.remaining.unwrap_or(bytes.len()).min(bytes.len()); + s.bytes.extend_from_slice(&bytes[..n]); + if let Some(left) = s.remaining.as_mut() { + *left -= n; + } + Ok(n) + } + fn flush(&mut self) -> io::Result<()> { + if self.0.lock().unwrap().fail_flush { + Err(io::Error::new(io::ErrorKind::BrokenPipe, "fixture")) + } else { + Ok(()) + } + } +} +struct RejectedSize; +impl MasterPty for RejectedSize { + fn resize(&self, _: PtySize) -> anyhow::Result<()> { + anyhow::bail!("fixture resize failure") + } + fn get_size(&self) -> anyhow::Result { + Ok(PtySize::default()) + } + fn try_clone_reader(&self) -> anyhow::Result> { + Ok(Box::new(io::empty())) + } + fn take_writer(&self) -> anyhow::Result> { + Ok(Box::new(io::sink())) + } + #[cfg(unix)] + fn process_group_leader(&self) -> Option { + None + } + #[cfg(unix)] + fn as_raw_fd(&self) -> Option { + None + } +} +fn fixture( + master: Option>, +) -> ( + Session, + Arc>, + Arc>>, +) { + let state = Arc::new(Mutex::new(State::default())); + let (audit, events) = Audit::memory(); + let mut s = Session::new(SessionConfig { + rows: 24, + cols: 80, + audit, + policy: PolicyStore::from_policy(Policy::parse(&common::Harness::test_policy()).unwrap()), + pty_writer: Box::new(Writer(state.clone())), + master, + pacing: Pacing::default(), + shell_pid: None, + }); + s.register_conn(1, ConnKind::Agent, "fixture", Box::new(|_: ServerEvent| {})); + s.agent_request_control(1).unwrap(); + (s, state, events) +} +#[test] +fn rejected_resize_does_not_publish_or_change_screen_dimensions() { + let (mut s, _, _) = fixture(Some(Box::new(RejectedSize))); + let frames = Arc::new(Mutex::new(Vec::new())); + let out = frames.clone(); + s.set_output_frame_sink(Some(Box::new(move |frame| { + out.lock().unwrap().push(frame.size.clone()) + }))); + let revision = s.screen().revision(); + assert!(matches!(s.resize(30, 104), Err(SessionError::ResizeFailed))); + assert_eq!(s.screen().size().cols, 80); + assert_eq!(s.screen().revision(), revision); + assert!(frames.lock().unwrap().is_empty()); +} +#[test] +fn partial_input_is_uncertain_and_not_automatically_replayed() { + let (mut s, io, events) = fixture(None); + io.lock().unwrap().remaining = Some(1); + assert!(matches!( + s.agent_type(1, "printf fixture"), + Err(SessionError::InputDeliveryUnknown) + )); + io.lock().unwrap().remaining = None; + assert!(matches!( + s.agent_type(1, "printf fixture"), + Err(SessionError::InputUnverified) + )); + assert!(matches!( + s.agent_send_key(1, "ENTER"), + Err(SessionError::InputUnverified) + )); + assert_eq!(io.lock().unwrap().bytes, b"p"); + assert!(!events.lock().unwrap().iter().any(|e| e.action == "exec")); +} +#[test] +fn uncertain_approval_delivery_cannot_be_granted_again() { + let (mut s, io, events) = fixture(None); + s.agent_type(1, "printf fixture > result").unwrap(); + let KeyResult::Pending { approval_id, .. } = s.agent_send_key(1, "ENTER").unwrap() else { + panic!() + }; + // Enter may have reached the shell before flush fails. + io.lock().unwrap().fail_flush = true; + assert!(matches!( + s.resolve_approval(&approval_id, Decision::Grant, "test"), + Err(SessionError::InputDeliveryUnknown) + )); + io.lock().unwrap().fail_flush = false; + assert!(s + .resolve_approval(&approval_id, Decision::Grant, "test") + .is_err()); + assert_eq!( + io.lock() + .unwrap() + .bytes + .iter() + .filter(|b| **b == b'\r') + .count(), + 1 + ); + assert!(!events + .lock() + .unwrap() + .iter() + .any(|e| e.action == "exec" && e.fields["approval"] == "granted")); +} diff --git a/crates/core/tests/input_privacy.rs b/crates/core/tests/input_privacy.rs index 789e580..de4e840 100644 --- a/crates/core/tests/input_privacy.rs +++ b/crates/core/tests/input_privacy.rs @@ -10,7 +10,7 @@ fn human_secrets_never_become_commands_titles_or_trace_payloads() { let trace = Arc::new(Mutex::new(Vec::new())); h.session.set_trace(trace.clone()); for input in [b"SYNTHETIC_PASSWORD\r".as_slice(), "한글_SYNTHETIC_KEY\r".as_bytes(), b"\x1b[200~SYNTHETIC_PASTE\x1b[201~\r"] { - h.session.human_input(input); + h.session.human_input(input).unwrap(); assert!(h.session.input_line().is_empty()); } assert!(!format!("{:?}", h.audit_events()).contains("SYNTHETIC")); @@ -22,7 +22,7 @@ fn human_secrets_never_become_commands_titles_or_trace_payloads() { #[test] fn untracked_partial_input_cannot_be_appended_to_or_reconstructed_by_an_agent() { let mut h = Harness::new(); h.agent(1, "agent"); - h.session.human_input(b"SYNTHETIC_PARTIAL"); + h.session.human_input(b"SYNTHETIC_PARTIAL").unwrap(); h.session.agent_request_control(1).unwrap(); assert!(matches!(h.session.agent_type(1, "echo unsafe"), Err(SessionError::InputPending))); assert!(matches!(h.session.agent_send_key(1, "ENTER"), Err(SessionError::InputPending))); @@ -78,7 +78,7 @@ fn real_hidden_password_input_in_an_ordinary_tab_is_not_audited() { while !String::from_utf8_lossy(&output.lock().unwrap()).contains("READY") { assert!(Instant::now() Vec { self.events @@ -257,7 +257,7 @@ fn long_input_does_not_block_output(agent: bool) { let writer = std::thread::spawn(move || { let mut s = session.lock(); if agent { s.agent_type(7, &command).unwrap(); } - else { s.human_input(command.as_bytes()); } + else { s.human_input(command.as_bytes()).unwrap(); } drop(s); let _ = done.send(()); }); @@ -270,7 +270,7 @@ fn long_input_does_not_block_output(agent: bool) { assert!(completed, "long PTY input blocked the output reader"); writer.join().unwrap(); if agent { h.engine.session().lock().agent_send_key(7, "ENTER").unwrap(); } - else { h.engine.write_input(b"\r"); } + else { h.engine.write_input(b"\r").unwrap(); } let expected = format!("LONG_OUTPUT<{payload}>\r\n"); h.until(|| String::from_utf8_lossy(&h.output.lock().unwrap()).contains(&expected)); if agent { h.engine.session().lock().agent_release_control(7).unwrap(); } @@ -345,7 +345,7 @@ fn completion_prompt_confirmation_clears_before_async_execution_hooks() { let mut s=session.lock(); // Long enough that a slow runner still observes the command running below; // 0.15s let it finish between the start hook and the assertion on macOS CI. - s.human_input(b"sleep 1\r"); + s.human_input(b"sleep 1\r").unwrap(); // Holding Session prevents the reader from processing the queued start // hook. Enter itself must close the automatic completion window. assert!(!s.completion_prompt_ready()); @@ -394,6 +394,8 @@ fn nested_foreground_commands_require_review_across_a_sharing_boundary() { assert!(s.resolve_approval(&approval_id,Approval::AllowSession,"test").is_err()); s.resolve_approval(&approval_id,Approval::Deny,"test").unwrap(); } + h.engine.write_input(b"\x03").unwrap(); + std::thread::sleep(Duration::from_millis(100)); h.send("exit");h.until(||session.lock().completion_prompt_ready()); assert!(!session.lock().review_required(),"only the matching outer-shell end restores local trust"); h.engine.terminate().unwrap(); @@ -405,7 +407,7 @@ fn delayed_private_hook_payloads_are_discarded_after_sharing() { let proof=h._dir.path().join("private-hook-proof"); { let mut s=session.lock();s.set_shared(false).unwrap(); - s.human_input(b"printf PRIVATE_HOOK_PAYLOAD > private-hook-proof\r"); + s.human_input(b"printf PRIVATE_HOOK_PAYLOAD > private-hook-proof\r").unwrap(); // The child completes while Session is locked, so its start/end mailbox // records cannot be consumed until after the sharing flag has changed. let until=Instant::now()+Duration::from_secs(3); @@ -429,7 +431,7 @@ fn idle_cancel_empty_enter_and_ignored_history_restore_prompt_policy() { let session = h.engine.session(); for input in [b"\x03".as_slice(), b"\r", b"\r\r"] { assert!(session.lock().completion_prompt_ready()); - h.engine.write_input(input); + h.engine.write_input(input).unwrap(); h.until(|| session.lock().completion_prompt_ready()); assert!(!session.lock().review_required()); assert!(matches!(session.lock().analyse_line("python3 --version").decision, Decision::Allow)); @@ -459,19 +461,19 @@ fn actual_readline_empty_edits_and_midline_kill_preserve_command_boundary() { let session = h.engine.session(); h.until(|| session.lock().screen().cursor_line().ends_with("conn-test$")); for edit in [b"\x1b[D".as_slice(), b"\x0c", b"\x7f"] { - h.engine.write_input(edit); + h.engine.write_input(edit).unwrap(); assert!(!session.lock().status().input_pending); } for (text, erase) in [("abc", b"\x7f\x7f\x7f".as_slice()), ("oneword", b"\x17"), ("word", b"\x15"), ("word", b"\x01\x1b[3~\x1b[3~\x1b[3~\x1b[3~")] { - h.engine.write_input(text.as_bytes()); + h.engine.write_input(text.as_bytes()).unwrap(); h.until(|| session.lock().screen().cursor_line().ends_with(text)); assert!(session.lock().status().input_pending); - h.engine.write_input(erase); + h.engine.write_input(erase).unwrap(); h.until(|| !session.lock().status().input_pending); } - h.engine.write_input(b"printf HUMAN_SUFFIX"); + h.engine.write_input(b"printf HUMAN_SUFFIX").unwrap(); h.until(|| session.lock().screen().cursor_line().contains("HUMAN_SUFFIX")); - h.engine.write_input(b"\x01\x15"); + h.engine.write_input(b"\x01\x15").unwrap(); h.until(|| session.lock().screen().cursor().col == 11); { let mut s = session.lock(); @@ -480,7 +482,7 @@ fn actual_readline_empty_edits_and_midline_kill_preserve_command_boundary() { assert!(matches!(s.agent_type(1, "printf AGENT"), Err(conn_core::session::SessionError::InputPending))); assert!(matches!(s.agent_send_key(1, "ENTER"), Err(conn_core::session::SessionError::InputPending))); } - h.engine.write_input(b"\x0b"); + h.engine.write_input(b"\x0b").unwrap(); h.until(|| !session.lock().status().input_pending); h.run("printf BOUNDARY_OK", 1); assert!(!format!("{:?}", h.starts()).contains("HUMAN_SUFFIX")); diff --git a/crates/frontend/src/lib.rs b/crates/frontend/src/lib.rs index 0c081bf..991f0ef 100644 --- a/crates/frontend/src/lib.rs +++ b/crates/frontend/src/lib.rs @@ -499,8 +499,7 @@ fn attend(state: &AppState, session: String) -> Result { fn input(state: &AppState, session: String, data: String) -> Result<(), String> { if cancel_pending(state, &session) { return Ok(()); } - engine(state, &session)?.session().lock().human_input(data.as_bytes()); - Ok(()) + engine(state, &session)?.session().lock().human_input(data.as_bytes()).map_err(|e| e.to_string()) } fn terminal_response(state: &AppState, session: String, data: String) -> Result<(), String> { @@ -512,8 +511,7 @@ fn resize(state: &AppState, session: String, rows: u16, cols: u16) -> Result<(), if let Some(pending) = state.pending_sessions.lock().get_mut(&session) { pending.rows = rows.max(1); pending.cols = cols.max(1); return Ok(()); } - engine(state, &session)?.session().lock().resize(rows, cols); - Ok(()) + engine(state, &session)?.session().lock().resize(rows, cols).map_err(|e| e.to_string()) } fn status(state: &AppState, session: String) -> Result { diff --git a/docs/browser-testing.ko.md b/docs/browser-testing.ko.md index 064179e..5c008d3 100644 --- a/docs/browser-testing.ko.md +++ b/docs/browser-testing.ko.md @@ -91,3 +91,16 @@ CONN_SSH_FIXTURE_CONFIG=/path/to/disposable/client.conf \ 같은 SSH 픽스처에 복합 명령으로 진입하려면 입력 검사 명령에 `CONN_SSH_COMPOUND_ENTRY=1`을 추가한다. 한글 검사는 원격 UTF-8 로케일(예: `LC_ALL=C.UTF-8`)이 필요하다. 사용자 서버가 아닌 폐기 가능한 픽스처에만 설정한다. 빌드된 배포 파일 자체를 검사하려면 `CONN_TEST_WEB_BUNDLE=/path/to/conn-web-0.8.7-linux-x64 npm run test:collaboration`을 실행한다. 구성 요소를 다시 빌드하지 않으며, 별도 상태 디렉터리에서 묶음의 실제 UI·서버·지속 MCP CLI를 사용한다. + +## 실제 Vim 편집 회귀 검사 + +위 Rust 실행 파일과 웹 UI를 빌드한 뒤 실행합니다. + +```sh +npm run test:editor -w @conn/collaboration-tests +CONN_EDITOR_BROWSER=webkit npm run test:editor -w @conn/collaboration-tests +``` + +필요하면 고정된 Playwright CLI로 WebKit과 호스트 의존성을 설치합니다. 실제 `vi` 실행 파일(`vi -Nu NONE -n -i NONE`), 격리된 임시 파일, 프로덕션 UI와 Rust PTY를 사용합니다. 비공유·공유 상태의 편집, 사람 개입, 커서 편집, 정확한 저장 내용, 편집 중 화면 재연결, 100줄 파일의 창 크기 변경 후 편집을 검사합니다. Vim을 실행한 뒤 타이핑 전에 터미널 포커스를 강제로 복구하지 않아 포커스 상실도 검출합니다. `CONN_SSH_FIXTURE_CONFIG`와 `CONN_SSH_COMPOUND_ENTRY`는 위의 폐기 가능한 SSH 픽스처를 선택하며, 그 안에 Vim 호환 `vi`가 설치되어 있어야 합니다. 운영 호스트를 대상으로 실행하지 마세요. + +`CONN_EDITOR_OUTPUT`으로 증거 폴더를 지정합니다. 예상하지 못한 브라우저 오류는 실패 처리합니다. 이전에도 관찰된 `ResizeObserver loop completed with undelivered notifications.` 경고는 별도로 집계해 `runtime.json`에 남깁니다. 편집 성공을 그 경고가 무해하다는 근거로 해석하지 않습니다. Linux WebKit을 포함한 브라우저 결과는 macOS 네이티브 키보드·IME·IPC 동작을 입증하지 않습니다. 미해결 네이티브 제보는 [0.8.7 Vim 조사 기록](vim-input-investigation.ko.md)을 참고하세요. diff --git a/docs/browser-testing.md b/docs/browser-testing.md index 9dae355..0901f1b 100644 --- a/docs/browser-testing.md +++ b/docs/browser-testing.md @@ -80,3 +80,16 @@ Browser results are Linux Chromium evidence. They do not establish macOS WebKit, To test the same SSH fixture through compound shell entry, add `CONN_SSH_COMPOUND_ENTRY=1` to the input-test command. Unicode cases require a UTF-8 remote locale (for example `LC_ALL=C.UTF-8`); configure only the disposable fixture, not a user server. To exercise an extracted release without rebuilding its components, run `CONN_TEST_WEB_BUNDLE=/path/to/conn-web-0.8.7-linux-x64 npm run test:collaboration`. The test still uses disposable state and drives the real packaged UI, server and persistent MCP CLI. + +## Actual Vim editing regression + +After building the Rust binaries and web UI above, run: + +```sh +npm run test:editor -w @conn/collaboration-tests +CONN_EDITOR_BROWSER=webkit npm run test:editor -w @conn/collaboration-tests +``` + +Install WebKit and its host dependencies with the pinned Playwright CLI when needed. This uses the real `vi` executable (`vi -Nu NONE -n -i NONE`), an isolated temporary file, the production UI and Rust PTY. It checks private/shared editing, human takeover, cursor editing, exact saved content, renderer reattachment during editing, and a 100-line buffer after resizing. The test does not refocus the terminal between launching Vim and typing, so it can detect lost focus. `CONN_SSH_FIXTURE_CONFIG` and `CONN_SSH_COMPOUND_ENTRY` select the same disposable SSH fixture described above; it must have Vim-compatible `vi` installed. Never point this test at a production host. + +`CONN_EDITOR_OUTPUT` selects the evidence directory. Unexpected browser errors fail the test. The previously observed `ResizeObserver loop completed with undelivered notifications.` warning is counted and preserved separately in `runtime.json`; successful editing does not establish that warning is harmless. Browser results, including Linux WebKit, do not prove native macOS keyboard/IME or IPC behavior. See the [0.8.7 Vim investigation](vim-input-investigation.md) for the unresolved native report. diff --git a/docs/getting-started.ko.md b/docs/getting-started.ko.md index c824b79..57dcea9 100644 --- a/docs/getting-started.ko.md +++ b/docs/getting-started.ko.md @@ -13,37 +13,37 @@ brew install --cask eggp-dev/tap/conn curl -fsSL https://conn.eggp.dev/install.sh | sh # Linux x86_64 ``` -cask는 서명·공증된 DMG를 설치하며 릴리스와 함께 공개된 SHA-256으로 확인합니다. Linux 스크립트는 AppImage를 내려받아 릴리스의 `SHA256SUMS`와 대조한 뒤 내 계정에만 설치합니다(`~/.local/bin`, 앱 메뉴 항목, sudo 불필요). 체크섬이 맞지 않으면 설치하지 않습니다. `CONN_VERSION=v0.8.7`로 버전을 고정할 수 있습니다. AppImage는 FUSE 2가 필요합니다. Ubuntu 24.04 이상에서는 `sudo apt install libfuse2t64`를 실행하세요. 두 방법 모두 이후에는 앱 안에서 업데이트됩니다. 셸에 넘기기 전에 스크립트를 읽어 보셔도 좋습니다: [`scripts/install.sh`](../scripts/install.sh). +cask는 서명·공증된 DMG를 설치하며 릴리스와 함께 공개된 SHA-256으로 확인합니다. Linux 스크립트는 AppImage를 내려받아 릴리스의 `SHA256SUMS`와 대조한 뒤 내 계정에만 설치합니다(`~/.local/bin`, 앱 메뉴 항목, sudo 불필요). 체크섬이 맞지 않으면 설치하지 않습니다. `CONN_VERSION=v0.8.8`로 버전을 고정할 수 있습니다. AppImage는 FUSE 2가 필요합니다. Ubuntu 24.04 이상에서는 `sudo apt install libfuse2t64`를 실행하세요. 두 방법 모두 이후에는 앱 안에서 업데이트됩니다. 셸에 넘기기 전에 스크립트를 읽어 보셔도 좋습니다: [`scripts/install.sh`](../scripts/install.sh). 직접 파일을 받으려면: -OS와 CPU에 맞는 [v0.8.7 프리뷰](https://github.com/eggp-dev/conn/releases/tag/v0.8.7)를 받으세요. 실제 서명 상태와 실행 검증 결과는 릴리스 안내에서 확인할 수 있습니다. +OS와 CPU에 맞는 [v0.8.8 프리뷰](https://github.com/eggp-dev/conn/releases/tag/v0.8.8)를 받으세요. 실제 서명 상태와 실행 검증 결과는 릴리스 안내에서 확인할 수 있습니다. | 플랫폼 | 다운로드 | 설치 | |---|---|---| -| Ubuntu x64 | [`.deb`](https://github.com/eggp-dev/conn/releases/download/v0.8.7/conn-v0.8.7-x86_64-unknown-linux-gnu-desktop.deb) | 아래 명령으로 설치한 뒤 앱 목록에서 Conn을 엽니다. | -| Ubuntu x64 | [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.7/conn-v0.8.7-x86_64-unknown-linux-gnu-desktop.AppImage) | 실행 권한을 준 뒤 파일을 엽니다. | -| macOS Apple Silicon | [`.dmg`](https://github.com/eggp-dev/conn/releases/download/v0.8.7/conn-v0.8.7-aarch64-apple-darwin-desktop.dmg) | DMG를 열고 Conn을 Applications로 옮긴 뒤 그곳에서 실행합니다. | -| Windows x64 | [설치 `.exe`](https://github.com/eggp-dev/conn/releases/download/v0.8.7/conn-v0.8.7-x86_64-pc-windows-msvc-setup.exe) | 설치 파일을 실행하고 시작 메뉴에서 Conn을 엽니다. | +| Ubuntu x64 | [`.deb`](https://github.com/eggp-dev/conn/releases/download/v0.8.8/conn-v0.8.8-x86_64-unknown-linux-gnu-desktop.deb) | 아래 명령으로 설치한 뒤 앱 목록에서 Conn을 엽니다. | +| Ubuntu x64 | [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.8/conn-v0.8.8-x86_64-unknown-linux-gnu-desktop.AppImage) | 실행 권한을 준 뒤 파일을 엽니다. | +| macOS Apple Silicon | [`.dmg`](https://github.com/eggp-dev/conn/releases/download/v0.8.8/conn-v0.8.8-aarch64-apple-darwin-desktop.dmg) | DMG를 열고 Conn을 Applications로 옮긴 뒤 그곳에서 실행합니다. | +| Windows x64 | [설치 `.exe`](https://github.com/eggp-dev/conn/releases/download/v0.8.8/conn-v0.8.8-x86_64-pc-windows-msvc-setup.exe) | 설치 파일을 실행하고 시작 메뉴에서 Conn을 엽니다. | 새 릴리스의 Intel Mac 배포는 잠시 중단합니다. 기존 Intel 파일은 [이전 릴리스](https://github.com/eggp-dev/conn/releases)에 유지하며 Intel Mac에 Apple Silicon 파일을 설치하지 마세요. Ubuntu에서는 다운로드한 폴더에서 실행합니다. ```sh -sudo apt install ./conn-v0.8.7-x86_64-unknown-linux-gnu-desktop.deb +sudo apt install ./conn-v0.8.8-x86_64-unknown-linux-gnu-desktop.deb ``` AppImage를 선택했다면 다음과 같이 실행합니다. ```sh -chmod +x ./conn-v0.8.7-x86_64-unknown-linux-gnu-desktop.AppImage -./conn-v0.8.7-x86_64-unknown-linux-gnu-desktop.AppImage +chmod +x ./conn-v0.8.8-x86_64-unknown-linux-gnu-desktop.AppImage +./conn-v0.8.8-x86_64-unknown-linux-gnu-desktop.AppImage ``` Linux 빌드 대상은 Ubuntu 24.04·26.04 x64이며 AppImage에도 시스템 라이브러리가 필요합니다. Windows 프리뷰는 의도적으로 무서명 배포하므로 SmartScreen·알 수 없는 배포자 경고가 나올 수 있고 관리되는 PC에서는 설치가 차단될 수 있습니다. Mac 공개 파일은 릴리스의 Developer ID 서명·공증 검사를 통과해야 합니다. 각 대상의 범위는 [플랫폼 지원](platform-support.ko.md)을 참고하세요. -원하면 파일과 함께 [SHA256SUMS](https://github.com/eggp-dev/conn/releases/download/v0.8.7/SHA256SUMS)를 받으세요. Linux는 `sha256sum --ignore-missing -c SHA256SUMS`, macOS는 `shasum -a 256 <파일>` 결과와 해당 줄 비교, Windows는 `Get-FileHash <파일> -Algorithm SHA256`을 사용합니다. 체크섬은 파일 손상을 확인하며 코드 서명과는 별개입니다. +원하면 파일과 함께 [SHA256SUMS](https://github.com/eggp-dev/conn/releases/download/v0.8.8/SHA256SUMS)를 받으세요. Linux는 `sha256sum --ignore-missing -c SHA256SUMS`, macOS는 `shasum -a 256 <파일>` 결과와 해당 줄 비교, Windows는 `Get-FileHash <파일> -Algorithm SHA256`을 사용합니다. 체크섬은 파일 손상을 확인하며 코드 서명과는 별개입니다. ## 2. 터미널 설정 diff --git a/docs/getting-started.md b/docs/getting-started.md index 6663e28..ff6e63d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -13,37 +13,37 @@ brew install --cask eggp-dev/tap/conn curl -fsSL https://conn.eggp.dev/install.sh | sh # Linux x86_64 ``` -The cask installs the signed and notarized DMG and checks it against the SHA-256 published with the release. The Linux script downloads the AppImage, verifies it against the release's `SHA256SUMS`, and installs it for your user only (`~/.local/bin`, an app-menu entry, no sudo); it refuses to install on a checksum mismatch. `CONN_VERSION=v0.8.7` pins a release. AppImages need FUSE 2: on Ubuntu 24.04 and newer, `sudo apt install libfuse2t64`. Both keep updating from inside the app. Read the script before piping it to a shell if you like: [`scripts/install.sh`](../scripts/install.sh). +The cask installs the signed and notarized DMG and checks it against the SHA-256 published with the release. The Linux script downloads the AppImage, verifies it against the release's `SHA256SUMS`, and installs it for your user only (`~/.local/bin`, an app-menu entry, no sudo); it refuses to install on a checksum mismatch. `CONN_VERSION=v0.8.8` pins a release. AppImages need FUSE 2: on Ubuntu 24.04 and newer, `sudo apt install libfuse2t64`. Both keep updating from inside the app. Read the script before piping it to a shell if you like: [`scripts/install.sh`](../scripts/install.sh). Or download a file yourself: -Download the [v0.8.7 preview](https://github.com/eggp-dev/conn/releases/tag/v0.8.7) for your OS and CPU. See the release notes for the exact signing and runtime verification results. +Download the [v0.8.8 preview](https://github.com/eggp-dev/conn/releases/tag/v0.8.8) for your OS and CPU. See the release notes for the exact signing and runtime verification results. | Platform | Download | Install | |---|---|---| -| Ubuntu x64 | [`.deb`](https://github.com/eggp-dev/conn/releases/download/v0.8.7/conn-v0.8.7-x86_64-unknown-linux-gnu-desktop.deb) | Run the command below, then open Conn from your applications. | -| Ubuntu x64 | [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.7/conn-v0.8.7-x86_64-unknown-linux-gnu-desktop.AppImage) | Make the file executable, then open it. | -| macOS Apple Silicon | [`.dmg`](https://github.com/eggp-dev/conn/releases/download/v0.8.7/conn-v0.8.7-aarch64-apple-darwin-desktop.dmg) | Open the DMG, drag Conn into Applications, then launch it there. | -| Windows x64 | [Installer `.exe`](https://github.com/eggp-dev/conn/releases/download/v0.8.7/conn-v0.8.7-x86_64-pc-windows-msvc-setup.exe) | Run the installer, then open Conn from the Start menu. | +| Ubuntu x64 | [`.deb`](https://github.com/eggp-dev/conn/releases/download/v0.8.8/conn-v0.8.8-x86_64-unknown-linux-gnu-desktop.deb) | Run the command below, then open Conn from your applications. | +| Ubuntu x64 | [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.8/conn-v0.8.8-x86_64-unknown-linux-gnu-desktop.AppImage) | Make the file executable, then open it. | +| macOS Apple Silicon | [`.dmg`](https://github.com/eggp-dev/conn/releases/download/v0.8.8/conn-v0.8.8-aarch64-apple-darwin-desktop.dmg) | Open the DMG, drag Conn into Applications, then launch it there. | +| Windows x64 | [Installer `.exe`](https://github.com/eggp-dev/conn/releases/download/v0.8.8/conn-v0.8.8-x86_64-pc-windows-msvc-setup.exe) | Run the installer, then open Conn from the Start menu. | Intel Mac distribution is paused for future releases. Previously published Intel packages remain in [past releases](https://github.com/eggp-dev/conn/releases); do not install the Apple Silicon package on an Intel Mac. For Ubuntu, run the matching command in your download directory: ```sh -sudo apt install ./conn-v0.8.7-x86_64-unknown-linux-gnu-desktop.deb +sudo apt install ./conn-v0.8.8-x86_64-unknown-linux-gnu-desktop.deb ``` Or, for AppImage: ```sh -chmod +x ./conn-v0.8.7-x86_64-unknown-linux-gnu-desktop.AppImage -./conn-v0.8.7-x86_64-unknown-linux-gnu-desktop.AppImage +chmod +x ./conn-v0.8.8-x86_64-unknown-linux-gnu-desktop.AppImage +./conn-v0.8.8-x86_64-unknown-linux-gnu-desktop.AppImage ``` The Linux build targets Ubuntu 24.04 and 26.04 x64; AppImage still depends on system libraries. Windows previews are intentionally unsigned, so SmartScreen or an unknown-publisher prompt may appear; managed PCs may block installation. Public Mac assets must pass the release's Developer ID signing and notarization checks. See [platform support](platform-support.md) for the limits of each target. -Optional: download [SHA256SUMS](https://github.com/eggp-dev/conn/releases/download/v0.8.7/SHA256SUMS) alongside your file. On Linux, run `sha256sum --ignore-missing -c SHA256SUMS`; on macOS, compare `shasum -a 256 ` with its line; on Windows, use `Get-FileHash -Algorithm SHA256`. Checksums detect corruption and are separate from code signing. +Optional: download [SHA256SUMS](https://github.com/eggp-dev/conn/releases/download/v0.8.8/SHA256SUMS) alongside your file. On Linux, run `sha256sum --ignore-missing -c SHA256SUMS`; on macOS, compare `shasum -a 256 ` with its line; on Windows, use `Get-FileHash -Algorithm SHA256`. Checksums detect corruption and are separate from code signing. ## 2. Set up your terminal diff --git a/docs/platform-support.ko.md b/docs/platform-support.ko.md index 58d2527..118ab5c 100644 --- a/docs/platform-support.ko.md +++ b/docs/platform-support.ko.md @@ -2,7 +2,7 @@ [English](platform-support.md) · 한국어 · [설치](getting-started.ko.md) · [릴리스](releasing.ko.md) -Conn은 MIT 라이선스 소스와 네이티브 바이너리를 [GitHub Releases](https://github.com/eggp-dev/conn/releases/tag/v0.8.7)로 제공합니다. 모든 데스크톱 패키지는 같은 Rust 엔진과 Svelte UI를 사용하며 브라우저 하네스는 개발용 어댑터입니다. +Conn은 MIT 라이선스 소스와 네이티브 바이너리를 [GitHub Releases](https://github.com/eggp-dev/conn/releases/tag/v0.8.8)로 제공합니다. 모든 데스크톱 패키지는 같은 Rust 엔진과 Svelte UI를 사용하며 브라우저 하네스는 개발용 어댑터입니다. ## 다음 프리뷰 릴리스 대상 diff --git a/docs/platform-support.md b/docs/platform-support.md index f082f1c..f037e11 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -2,7 +2,7 @@ English · [한국어](platform-support.ko.md) · [Install](getting-started.md) · [Release](releasing.md) -Conn provides its MIT-licensed source and native binaries through [GitHub Releases](https://github.com/eggp-dev/conn/releases/tag/v0.8.7). All desktop packages use the same Rust engine and Svelte UI. The browser harness is a development adapter. +Conn provides its MIT-licensed source and native binaries through [GitHub Releases](https://github.com/eggp-dev/conn/releases/tag/v0.8.8). All desktop packages use the same Rust engine and Svelte UI. The browser harness is a development adapter. ## Targets for upcoming preview releases diff --git a/docs/protocol.md b/docs/protocol.md index 0616704..120e2e0 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -98,7 +98,7 @@ See [trust limits](security.md#one-terminal-one-presented-surface). | `type` | `{text}` | `{typed}`; no execution newline | | `send_key` | `{key,intent?}` | Sent/executed/cancelled/rejected/denied result; Enter requires intent when policy specifies it | | `interrupt` | — | Ctrl-C through the same PTY; requires this agent's current authority | -| `check_approval` | `{approvalId}` | State of the caller's command approval | +| `check_approval` | `{approvalId}` | State of the caller's command approval; `inputRetained` reports cancelled input awaiting explicit recovery | | `control_request_state` | `{requestId}` | Pending/granted/denied/expired control request | | `proposal_state` | `{proposalId}` | Drafting/ready/executed/rejected/denied proposal | | `exec_state` | `{execId}` | Scheduled/executed/cancelled execution | @@ -123,6 +123,18 @@ Use the review UI for structural policy details and the current snapshot for ter context. `conn log` is an explicit local file read using the CLI process's OS privileges, not a screen/history capability granted by this protocol. +An edited command must remain fully tracked to enter the policy gate. Completion, +history and unsupported edits return `input_unverified` instead of evaluating a +cursor-row fragment. Cancellation at a confirmed idle shell waits for a fresh +input boundary; otherwise the typed line stays visible and new agent appends are +blocked until explicit recovery. Human input cancels pending review and takes over +the visible line; it is never silently rewritten with guessed editing keys. + +`input_outcome_unknown` reports a failed PTY write or flush whose partial delivery +cannot be excluded. No automatic replay is allowed. `resize_failed` reports that +the local PTY did not confirm the requested size; the core does not publish that +size as applied. This does not establish the dimensions of a remote SSH endpoint. + ## Original control request Include `command` when a planned command is known; it is a declaration, not execution @@ -154,7 +166,7 @@ content. Revocation cannot recall bytes already delivered to the client/model. `hello_required`, `owner_required`, `surface_unavailable`, `not_available`, `busy`, `not_controller`, `lease_expired`, `process_exited`, `invalid_input`, -`not_found`, `rate_limited`, `input_pending`, `approval_pending`, `exec_pending`, `proposal_pending`, `intent_required`, +`not_found`, `rate_limited`, `input_pending`, `input_unverified`, `input_outcome_unknown`, `resize_failed`, `approval_pending`, `exec_pending`, `proposal_pending`, `intent_required`, `masked`, `control_denied`, `wrong_mode`, `unsupported`, `connection_closing`, `admission_pending`, `admission_denied`, `parse`. Private/unauthorized session access uses a generic unavailable response. The owner can start the app with `CONN_TRACE_REFUSALS=1` to print the cause of each such refusal to stderr (reasons and identifiers only). A client should diff --git a/docs/release-0.8.8-validation.ko.md b/docs/release-0.8.8-validation.ko.md new file mode 100644 index 0000000..24f261f --- /dev/null +++ b/docs/release-0.8.8-validation.ko.md @@ -0,0 +1,64 @@ +# 0.8.8 릴리스 검증 + +[English](release-0.8.8-validation.md) · [원인 조사](terminal-input-candidates.ko.md) · [Vim 조사](vim-input-investigation.ko.md) + +2026-09-23. 소스 검증 기록이다. 태그의 GitHub Actions 실행과 내려받은 배포 +파일로 빌드·서명·공증·공개 배포를 별도 확인하며, 이 문서는 그 성공을 미리 선언하지 않는다. + +## 수정 내용 + +- 정책과 승인에 에이전트가 입력한 전체 명령을 사용한다. 지원되는 ASCII 커서 + 편집은 커서 양쪽 문자열을 보존한다. 불확실한 자동완성·히스토리·Unicode 편집은 + 화면 한 행으로 전체 명령을 추정하지 않고 제출을 중단한다. 사람 입력 내용은 + 여전히 저장하지 않는다. +- 거절·만료는 권한부터 취소한다. 확인된 로컬 유휴 셸에서만 Ctrl-C로 정리하고 + 빈 입력 경계가 복원된 뒤 이어간다. 알 수 없는 전경 프로그램은 입력을 보존하고 + `inputRetained`를 알리며, 명시적 복구 전에는 새 에이전트 입력을 붙이지 않는다. + 사람의 개입은 제어권·대기 승인을 취소하고 보이는 줄을 직접 편집·제출한다. +- 입력 write/flush 실패는 `input_outcome_unknown`으로 반환한다. 일부 전달됐을 수 + 있는 Enter를 자동 재전송하거나 승인 실행 성공으로 기록하지 않는다. 크기는 로컬 + PTY의 적용·조회가 성공한 뒤에만 게시한다. 원격 SSH 끝점의 적용까지 보장하지 않는다. +- 이전 출력 큐의 콜백으로 새 화면이 준비 완료가 되지 않게 한다. 현재 체크포인트가 + 적용될 때까지 입력을 멈추고, 지연되면 복원 상태와 명시적 화면 복구를 제공한다. + 보류된 입력을 자동 재전송하지 않는다. + +## 검증 + +격리된 상태 디렉터리와 합성 데이터로 검사한다. 브라우저 검사는 배포용 공통 UI, +실제 Rust 웹 호스트·PTY, 지속 MCP 연결을 사용한다. 별도 UI·정책 엔진을 대체하지 않는다. + +실제 셸에서 승인 명령 전체, 정책 결과, +임시 파일 내용, 취소 후 잔여 입력과 다음 명령을 검사하며, 코어/xterm 화면·커서 +일치만으로 의미상 정상이라고 판단하지 않는다. + +| 검사 | 결과 | 범위 | +| --- | --- | --- | +| Rust 워크스페이스 | 288 통과, 기존 1 ignored | 실제 Bash 승인 대상, 부분 입력·flush·resize 실패 주입 | +| 공통 UI | 68 통과 | 이전 체크포인트 콜백과 불확실한 네이티브 입력 응답 | +| 배포 웹/MCP 협업 | 10 통과 | 같은 owner 계약, 지속 연결, 실제 PTY | +| 로컬 입력·렌더링 | 25 통과 | 장문 편집, ANSI·한글 프롬프트, 승인·거절·다음 명령, 고정 창 모션 | +| 직접 SSH 입력·렌더링 | 26 통과 | 폐기용 루프백 OpenSSH, 원격 Bash, UTF-8 | +| 실제 Vim 편집·저장 | 20 통과 | Linux Chromium/WebKit × 로컬/SSH × 비공개·공유·재연결·긴 버퍼 5개 시나리오 | +| 코어/xterm 화면 비교 | 85 통과 | 화면과 커서 | +| 체크포인트 복원 | 984 통과 | 화면·기록·커서·스타일·모드·후속 출력·크기 변경 | +| 앱 수명 | 통과 | 공통 앱과 owner·전송 시험 어댑터. 네이티브 OS 실행과 구분 | +| 릴리스 도구 | 81 통과 | 버전·배포 파일·체크섬·서명 근거·압축 파일 검사 | +| 웹·데스크톱 프런트 빌드 | 통과 | Svelte 검사와 번들. 기존 Svelte·번들 경고는 남음 | +| owner 계약·UI 경계 | 통과 | 공통 컴포넌트·런타임 경계 | + +## 한계와 실패 실험 + +첫 폐기용 OpenSSH 환경이 UTF-8 대신 POSIX 로케일을 상속해 긴 한글·이모지 화면 +비교가 실패했다. 시험 환경을 `C.UTF-8`로 수정한 뒤 전체 입력 검사가 통과했다. +이는 시험 환경 보정이며 임의의 바이트 단위 편집 환경을 고친 제품 수정은 아니다. +사용자 프롬프트를 자동 교체하거나 색상을 제거하지 않는다. + +첫 SSH Vim 시험에는 `vi`가 설치돼 있지 않았다. 폐기용 컨테이너에 Vim을 설치한 +뒤 편집·저장 검사가 통과했다. WebKit 편집 실행마다 ResizeObserver 경고 1회를 +별도 기록했다. 편집 성공만으로 모든 네이티브 환경에서 경고가 무해하다고 단정하지 않는다. + +Linux Chromium/WebKit의 Vim 검사는 네이티브 Mac WebKit/Tauri 실기 검증과 다르다. +사용자 Mac의 Vim 무응답, 실제 SSH 게이트웨이, 물리 IME, 설치 앱의 업그레이드·재시작은 +이번 세션에서 검증하지 못했다. ANSI 프롬프트 설정·원격 크기는 현장 화면 문제의 +다른 원인일 수 있다. 재현한 Conn 결함을 수정하되 모든 사진의 원인을 확정하지 않는다. +출력에 개행을 삽입하거나 모션을 없애지 않으며, 기존 무제한 PTY 출력 큐는 유지한다. diff --git a/docs/release-0.8.8-validation.md b/docs/release-0.8.8-validation.md new file mode 100644 index 0000000..aec9627 --- /dev/null +++ b/docs/release-0.8.8-validation.md @@ -0,0 +1,72 @@ +# 0.8.8 release validation + +[한국어](release-0.8.8-validation.ko.md) · [Investigation](terminal-input-candidates.md) · [Vim investigation](vim-input-investigation.md) + +2026-09-23. Source validation is recorded here. The tagged GitHub Actions run and +downloaded release assets establish build, signing, notarization and distribution +results separately; this report does not predeclare them. + +## Corrections + +- Policy and review use the complete agent-owned command. Supported ASCII cursor + edits retain text on both sides of the cursor. Unknown completion/history edits + and ambiguous Unicode editing refuse submission rather than inferring a command + from one screen row. Human input is still content-free bookkeeping. +- Denial/expiry cancels permission immediately. Only a confirmed idle local shell + receives automatic Ctrl-C; subsequent input waits for a recovered boundary. + Unknown foreground programs keep their visible input, expose `inputRetained`, + and refuse agent appends until explicit recovery. Human intervention revokes + agent control, cancels review and edits/submits the visible line directly. +- Write/flush errors return `input_outcome_unknown`. A failed or partially delivered + Enter is never replayed or recorded as successfully approved. Resize publishes + dimensions only after the local PTY accepts and reports the requested size. + This does not prove a remote endpoint accepted an SSH window-change message. +- Obsolete output-queue callbacks cannot mark a new attachment ready. Input stays + paused until the current checkpoint is applied. A prolonged wait exposes a + restoration status and explicit screen recovery, with no input replay. + +## Verification + +Tests use isolated state directories and synthetic data. Browser suites mount the +production common UI against the actual Rust web host, PTYs and persistent MCP. +They do not replace the desktop UI or policy engine with a test implementation. + +Real-shell assertions check +the exact reviewed command, policy outcome, disposable file contents, cancellation +remainder and next command, as well as core/xterm grid and cursor equality. + +| Check | Result | Boundary | +| --- | --- | --- | +| Rust workspace | 288 passed, 1 existing ignored | Includes real Bash review identity and injected partial write/flush/resize failures | +| Common UI | 68 passed | Includes obsolete checkpoint callbacks and uncertain native input responses | +| Production web/MCP collaboration | 10 passed | Same owner contract, persistent connection and real PTY | +| Local input/rendering | 25 passed | Wrapped edits, ANSI/Korean prompts, review, denial, next command, fixed-window motion | +| Direct SSH input/rendering | 26 passed | Disposable loopback OpenSSH, remote Bash and UTF-8 locale | +| Real Vim input/save | 20 passed | Linux Chromium/WebKit × local/SSH × 5 scenarios: private/shared, live/reattached, long buffer resize | +| Core/xterm screen parity | 85 passed | Grid and cursor | +| Checkpoint restoration | 984 passed | Grid, history, cursor, styles, modes, continuation and resize | +| App lifetime | PASS | Common app with owner/transport fixtures; separate from native OS execution | +| Release tooling | 81 passed | Versions, assets, checksums, signing evidence and archive validation | +| Web and desktop frontend builds | PASS | Svelte checks and compiled bundles; existing Svelte/bundle warnings remain | +| Owner contract and UI boundaries | PASS | Same common components and runtime boundaries | + +## Limits and failed experiments + +The first disposable OpenSSH run inherited POSIX rather than UTF-8 locale. Long +Korean/emoji input failed the screen comparison. The fixture was corrected to +`C.UTF-8`; the complete input/rendering suite then passed. This is an environment +correction, not a product fix for arbitrary byte-oriented readline environments. +User shell configuration is never automatically rewritten or stripped of color. + +The initial SSH Vim fixture lacked `vi`; after installing Vim in that disposable +container, edit/save checks passed. One ResizeObserver warning was observed in +each WebKit editor run and recorded separately; successful edits do not establish +that the warning is harmless in every native environment. + +Linux Chromium/WebKit Vim coverage is not native macOS WebKit/Tauri acceptance. +The user's Mac Vim freeze, actual SSH gateway, physical IME and installed-app +upgrade/relaunch remain unverified in this session. ANSI prompt configuration and +remote dimensions may still contribute to the reported rendering symptoms; the +reproduced Conn defects are fixed without declaring all screenshots explained. +Output is not given synthetic newlines, terminal motion is retained, and the +previous unbounded PTY output queue is unchanged. diff --git a/docs/releasing.ko.md b/docs/releasing.ko.md index d49baae..5dc0914 100644 --- a/docs/releasing.ko.md +++ b/docs/releasing.ko.md @@ -65,7 +65,7 @@ Actions는 커밋 SHA로 고정합니다. PR에는 서명 secrets를 제공하 1. 각 네이티브 러너에서 같은 버전의 CLI와 데스크톱 패키지를 빌드합니다. 2. Mac에서는 [서명 안내](macos-signing.ko.md)에 따라 앱, 내장 CLI, 독립 CLI, DMG의 서명·공증을 수행합니다. Apple Silicon의 승인 결과와 최종 파일 해시가 담긴 보고서를 만듭니다. 3. `release.py package`가 파일명을 정리합니다. `finalize`는 설치 파일·업데이트 파일·서명과 유효한 Apple Silicon 보고서를 모두 확인한 뒤 `SHA256SUMS`와 영·한 안내를 만듭니다. 안내는 `scripts/release_notes.md`에 이 버전의 파일 이름과 변경 이력 섹션을 채운 것입니다. -4. 해당 커밋의 CI와 모든 빌드·서명 작업이 통과하면 `draft`가 **14개 자산**을 업로드합니다. 바이너리·설치 파일 7개, Mac 업데이트 아카이브, 업데이트 서명 3개, `latest.json`, 서명 보고서, `SHA256SUMS`입니다. 미공개 프리릴리스를 생성하거나 갱신하며 이미 공개한 릴리스는 수정하지 않습니다. +4. 해당 커밋의 CI와 모든 빌드·서명 작업이 통과하면 `draft`가 **15개 자산**을 업로드합니다. 바이너리·설치 파일 7개, Linux 독립 웹 아카이브, Mac 업데이트 아카이브, 업데이트 서명 3개, `latest.json`, 서명 보고서, `SHA256SUMS`입니다. 미공개 프리릴리스를 생성하거나 갱신하며 이미 공개한 릴리스는 수정하지 않습니다. 재실행으로 미완성 초안을 보완할 수 있습니다. 자동 공개는 하지 않습니다. Actions 임시 산출물은 7일간 보관하며 업로드한 릴리스 파일은 유지됩니다. CLI 압축 파일에는 라이선스와 설치 안내가 들어갑니다. 플랫폼별 서명 업데이트 메타데이터도 같은 릴리스에 게시합니다. diff --git a/docs/releasing.md b/docs/releasing.md index 7d0f40b..f5d58b1 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -65,7 +65,7 @@ The release stages are: 1. Build the matching CLI and desktop package on each native runner. 2. On Mac, sign and notarize the app, embedded CLI, standalone CLI and DMG as described in [macOS signing](macos-signing.md). Generate the Apple Silicon report, including acceptance and final asset hashes. 3. `release.py package` normalizes filenames. `finalize` requires all installers, updater archives/signatures and the valid Apple Silicon report before writing `SHA256SUMS` and the bilingual notes: `scripts/release_notes.md` filled with this version's asset names and changelog section. -4. After the exact commit's CI and all build/signing jobs pass, `draft` uploads **14 assets**: seven binaries/installers, one macOS updater archive, three updater signatures, `latest.json`, one signing report and `SHA256SUMS`. It creates or updates an unpublished prerelease and refuses to modify a public release. +4. After the exact commit's CI and all build/signing jobs pass, `draft` uploads **15 assets**: seven binaries/installers, one Linux standalone web archive, one macOS updater archive, three updater signatures, `latest.json`, one signing report and `SHA256SUMS`. It creates or updates an unpublished prerelease and refuses to modify a public release. Re-running can repair an incomplete draft. No workflow publishes automatically. Actions retains temporary build artifacts for seven days; uploaded release assets persist. The CLI archives contain the license and installation notes. The updater manifest is release-scoped and discovered via GitHub release metadata, including the explicit preview channel. diff --git a/docs/terminal-input-candidates.ko.md b/docs/terminal-input-candidates.ko.md new file mode 100644 index 0000000..9700c57 --- /dev/null +++ b/docs/terminal-input-candidates.ko.md @@ -0,0 +1,81 @@ +# 터미널 입력·줄바꿈 조사와 수정 후보 + +[English](terminal-input-candidates.md) + +2026-09-23 · 조사 기준 `05b6214` / 0.8.7 소스. **분석 결과이며 제품 수정·macOS 검증·배포 완료 보고가 아니다.** 기존 Vim 조사 변경은 보존했다. + +## 결론과 증거 범위 + +사용자 사진의 원인을 한 가지로 확정하지 않는다. 개행 없는 출력, 입력 줄의 재그리기, 승인 대상 명령의 식별, 취소 후 남은 입력은 서로 다른 문제다. 색상 설정과 무관하게 Conn에서 재현되는 결함도 발견했다. + +실험은 Linux Chromium, 빌드된 공통 웹 UI, 실제 Rust 웹 호스트·Bash PTY, 지속적인 MCP 연결을 사용했다. 승인·거절은 실제 공통 UI를 조작했다. 파일은 임시 시험 디렉터리의 합성 데이터만 사용했고 사용자 Mac·원격 서버·인증 입력에는 접근하지 않았다. 아래 커서 이동은 별도 설명이 없으면 MCP의 LEFT 키다. + +| 관찰 | 증거 수준 | 의미 | +| --- | --- | --- | +| 같은 긴 `printf` 파일 기록 명령이 직접 Enter이면 `overwrite file` 승인 대기, LEFT 한 번 후 Enter이면 명령 꼬리만 평가해 자동 실행 | 실제 UI·PTY 재현, 시험 파일 내용 확인 | 승인 정책과 실제 실행 명령의 대응이 깨짐 | +| 커서가 중간에 있는 명령을 거절하면 뒤쪽 `probe.out`이 남고 다음 입력이 `printf NEXTprobe.out`으로 합쳐짐 | 실제 UI·PTY 재현 | 취소 완료를 빈 입력 완료로 오인 | +| `[DEV]한글$` 프롬프트에서 LEFT 후 제출하면 승인 명령의 `printf`가 `intf`로 표시됨 | 실제 UI·PTY 재현 | 화면 셀 열과 Unicode 문자 인덱스를 혼동 | +| 비표시 구간 표시가 없는 ANSI 색상 Bash 프롬프트에서 Ctrl-A/E 후 커서·추가 입력 위치가 틀어짐 | 별도 실제 UI·PTY 재현 | 가능한 현장 원인. 사용자 PS1·셸은 미확인 | +| 실제 PTY 80열 / 코어·UI 104열에서 LEFT 30회가 잘못된 위치로 이동. 모두 104열인 대조군에서는 정상 | 의도적으로 만든 크기 불일치, 실제 UI·PTY 대조 실험 | 정상 색상 프롬프트에서도 크기 불일치가 커서 오류를 만듦. 현장 발생 여부 미확인 | +| 출력 큐를 clear한 뒤에도 이미 진행 중인 이전 체크포인트의 완료 콜백이 호출됨 | 실제 공통 큐의 지연 완료 단위 재현 | 이전 세대의 콜백이 화면 준비 상태에 영향을 줄 여지. Mac Vim 장애의 원인으로 확정하지 않음 | +| PTY resize와 일반 입력 write/flush 오류가 상위 호출로 반환되지 않음 | 코드 확인 | I/O 실패 시 화면 크기나 입력 성공을 잘못 보고할 잠재 경로. 실제 오류 주입/현장 발생은 미검증 | + +## 우선 수정 후보 + +### A. 승인 대상 명령의 완전성과 출처 보존 — P0 + +`crates/core/src/input.rs:156`은 추적이 불확실해지면 커서가 있는 **화면 한 행**에서 명령을 복원한다. 줄바꿈된 명령의 뒷부분을 전체 명령으로 오인할 수 있다. `strip_prompt`는 셀 단위 `prompt_col`을 `Vec` 인덱스로 사용해 한글·전각 문자에서 다른 오류를 만든다. `session.rs:1766`은 이 결과로 정책을 판단하지만 실제 제출은 PTY에 Enter를 보내므로 전체 물리 입력이 실행된다. + +재현 명령은 `printf '%s\n' > probe.out 'WRAP_BEGIN_…_END'`이며 중간은 합성 문자 200개다. 편집 없는 경우 승인 대기·파일 없음, LEFT 한 번 후에는 반환 명령이 `…_END'`로 축약되고 승인 없이 전체 파일 기록이 실행됐다. 삭제나 운영 파일을 사용하는 실험은 하지 않았다. + +권장 방향: + +- 확실히 추적되는 에이전트 입력과 불확실한 입력을 구분한다. 현재 화면 한 행이나 과거 입력을 임의로 완전한 명령으로 승격하지 않는다. +- 지원하는 편집은 커서 위치와 함께 추적하고, 화면에서 복원할 때는 셀 폭·실제 줄바꿈 정보·확인된 프롬프트 경계를 사용한다. 화면 밖 기록이나 숨겨진 텍스트로 부족한 정보를 채우지 않는다. +- 전체 명령을 확인할 수 없는 경우 그 조각으로 정책을 판단하거나 승인을 받아 Enter를 보내지 않는다. 해당 입력만 다시 확인·정리하는 흐름을 제공한다. 일반 명령의 자동 실행이나 SSH 전체를 일괄 제한하지 않는다. +- 승인 카드, 정책 평가, 실행·감사 기록이 같은 명령 출처를 사용하도록 한다. 단순히 dirty 플래그를 무시하고 원래 입력만 신뢰하는 변경은 제외한다. + +완료 기준: 줄바꿈 후 LEFT/RIGHT/Backspace/Tab/히스토리, 한글·결합 문자 프롬프트, 화면보다 긴 입력에서 부분 문자열이 승인·정책의 전체 명령이 되지 않는다. 정상적인 일반 명령 자동 실행은 유지한다. + +### B. 취소와 물리 입력 정리를 분리 — P1 + +`session.rs:1985`의 거절·만료는 Ctrl-U를 보내고 바로 추적 상태를 초기화한다. Bash 기본 편집에서 Ctrl-U는 커서 앞쪽을 지우며 뒤쪽은 남을 수 있다. 같은 함수가 사람 개입·연결 해제·제어 반환 등에서도 사용되므로 영향 범위는 거절 버튼 하나보다 넓다. 정책 자체의 Deny 경로에도 같은 가정이 있다. 사람 입력 추적 코드는 이미 Ctrl-U만으로 빈 입력을 단정하지 않지만 에이전트 승인 취소는 그 기준을 공유하지 않는다. + +권장 방향: 승인 취소는 즉시 확정하되 실제 입력은 별도 상태로 보존한다. 확인된 셸 편집 상태에서 안전하게 전체 입력을 취소하고 **출력에서 빈 경계가 확인된 뒤** 추적을 초기화한다. 확인할 수 없으면 남은 입력을 그대로 보여주고 정리가 필요함을 표시한다. 그 상태에 새 에이전트 명령을 이어 붙이지 않는다. 편집기·다른 전경 프로그램에 Ctrl-C나 편집 키를 일괄 주입하지 않는다. 사람 개입의 원시 입력 내용은 보관하지 않는다. + +완료 기준: 커서 앞·중간·끝, 여러 줄 입력에서 거절·만료·제어 반환·연결 종료·사람 개입 후 숨은 잔여 명령이나 자동 재실행이 없다. 취소 실패가 영구적인 설명 없는 `input_pending`으로 남지 않는다. + +### C. PTY I/O 성공과 화면 상태의 대응 강화 — 우선 보강 + +`session.rs:1092`는 먼저 화면 모델 크기를 변경하고 출력 프레임을 보낸 뒤 PTY resize 오류를 무시한다. `write_pty`도 write/flush 결과를 버린다. UI 큐의 요청 순서가 맞는 것과 실제 PTY 처리가 성공한 것은 다르다. + +권장 방향: resize 실패를 호출자에게 전달하고 실패한 크기를 확정하지 않는다. 요청 크기·로컬 PTY 적용 크기·화면 적용 크기를 구분해 내용 없는 진단을 제공한다. 입력 전송이 부분 성공했을 수 있으면 성공으로 보고하거나 자동 재전송하지 않는다. 원격 크기는 로컬 PTY 검사만으로 보증하지 않는다. + +크기 대조 실험에서는 같은 입력의 커서가 0부터 세어 `(행 3, 열 12)`에 있었다. LEFT 30회 후 정상 대조군은 `(행 2, 열 86)`, PTY만 80열인 경우는 `(행 3, 열 0)`이었다. 두 경우 모두 코어/xterm 화면·커서 일치 검사는 통과했다. 승인 패널 개방은 두 경우 모두 PTY를 18행·104열로 바꿨다. 크기 불일치 자체는 시험이 주입한 것이며 Conn이 사용자 환경에서 그 불일치를 만들었다는 증거는 아니다. + +완료 기준: 실패하는 PTY 어댑터와 실제 SSH 시험에서 크기 적용 실패를 검출한다. 고정 창의 패널 반복·빠른 열기/닫기·장문 출력 중 입력과 크기 변경 순서를 확인한다. 매 프레임 resize하거나 지연만 늘리는 변경은 제외한다. + +### D. 화면 재동기화의 세대와 준비 상태 보호 — 범위가 작은 보강 후보 + +`packages/ui/src/lib/terminalOutput.ts:18`의 이전 write 완료 콜백은 `clear()` 후에도 실행된다. `Term.svelte`의 체크포인트 완료 콜백은 `mounted`만 확인하고 `renderReady=true`로 바꾼다. 이전 연결/복원 세대의 완료가 새로운 준비 상태를 바꾸지 못하도록 세대 검증을 추가하는 방향이 타당하다. + +체크포인트 대기 중 입력을 조용히 버리는 상태가 오래 지속되는지도 확인해야 한다. 복원이 지연되면 기존 연결 UI에서 상태와 명시적 재연결을 제공하고 입력을 자동 재전송하지 않는다. 입력 내용 없이 포커스·준비 여부·큐 진행·전송 완료 여부만 진단한다. 이는 Vim 미응답을 조사하기 위한 후보이며 원인 확정이 아니다. + +완료 기준: 지연된 이전 체크포인트 완료, 연결 종료와 즉시 재연결, Vim 실행 중 화면 재연결을 검사한다. 오래된 콜백이 준비 상태를 복구하지 않고 현재 체크포인트 적용 후에만 입력을 받는다. + +## 이번 패치에 포함하지 않을 추측성 변경 + +- 사용자 PS1 자동 교체, 색상 일괄 제거, 원격 환경 탓으로 종결. +- 모든 출력에 개행 추가. JSON 뒤 프롬프트가 붙는 것은 본문 끝 개행 유무를 먼저 확인한다. +- 모션 전체 제거, 고정 80열 강제, 셸이 처리했는지 확인하지 않는 resize 재시도. +- 일반 셸 입력과 Vim 입력을 같은 장애라고 단정하거나 Linux 웹 검사로 Mac 수정 완료 선언. + +## 검증 묶음과 제안 순서 + +**A와 B를 첫 수정 묶음으로 권장한다.** C는 실패 계약을 명확히 하는 보강으로 함께 설계하고, D는 이전 콜백의 세대 경계를 독립적으로 검증해 적용한다. 올바른/잘못된 색상 프롬프트 비교는 검증 묶음에 포함하되 사용자 설정 자동 수정을 제품 기능으로 추가하지 않는다. + +최종 검증은 UI·코어 화면 일치뿐 아니라 의도한 커서 위치, 승인에 표시된 전체 명령, 정책 결과, 실제 합성 출력/파일, 취소 후 잔여 입력을 함께 확인한다. 로컬과 실제 폐기 가능한 SSH, 일반/색상/한글/여러 줄 프롬프트, 긴 입력 편집과 승인·거절·사람 개입, 고정 창 패널 반복, 네이티브 Mac Vim 편집·저장이 필요하다. SSH 게이트웨이 환경과 물리 IME는 별도 근거가 필요하다. + +이 조사에서 추가한 두 웹 진단 스크립트와 합성 스크린샷·JSON은 `/tmp/conn-terminal-investigation/`에 보존했다. 스크립트 출력의 `PASS`는 캡처·코어/렌더러 일치 성공이며 의미상 올바른 입력을 보증하지 않는다. 정식 회귀 테스트에는 올바른 동작을 기대하는 assertion을 추가해야 한다. + +참고: [GNU Bash 줄 삭제 동작](https://www.gnu.org/s/bash/manual/html_node/Commands-For-Killing.html), [Linux PTY 크기 변경·오류 반환](https://man7.org/linux/man-pages/man2/TIOCSWINSZ.2const.html), [별도 Vim 입력 조사](vim-input-investigation.ko.md). diff --git a/docs/terminal-input-candidates.md b/docs/terminal-input-candidates.md new file mode 100644 index 0000000..8a336df --- /dev/null +++ b/docs/terminal-input-candidates.md @@ -0,0 +1,69 @@ +# Terminal input investigation and fix candidates + +[한국어](terminal-input-candidates.ko.md) + +2026-09-23 · Source baseline `05b6214` / 0.8.7. **Investigation only: no product fix, native macOS acceptance, or deployment is claimed.** Existing Vim investigation changes were preserved. + +## Findings and evidence boundaries + +The user photos do not establish a single cause. Missing trailing newlines, input redraw, command identity at approval, and cancellation leftovers are separate concerns. Some Conn defects reproduce with correctly marked colored prompts. + +Experiments used Linux Chromium, the built common web UI, real Rust web host/Bash PTY, a persistent MCP process, and real owner approval controls. Only synthetic data and disposable files were used. No user Mac, remote server, or authentication input was accessed. Cursor navigation below uses MCP LEFT unless stated otherwise. + +| Finding | Evidence | Consequence | +| --- | --- | --- | +| An unchanged long printf/redirection command needs overwrite approval; after one LEFT it is assessed from a suffix and executes automatically | Real UI/PTY; actual output file verified | Policy command and physical submitted command disagree | +| Denial with a middle-of-line cursor leaves `probe.out`; the next accepted input becomes `printf NEXTprobe.out` | Real UI/PTY | Cancelled does not mean physically empty | +| With `[DEV]한글$`, LEFT then submit turns `printf` into `intf` in review text | Real UI/PTY | Cell columns used as Unicode character indices | +| Unmarked ANSI prompt sequences misposition the cursor after Ctrl-A/E | Separate real UI/PTY comparison | Plausible field mechanism; user shell/PS1 unverified | +| LEFT 30 times mispositions the cursor with PTY 80 columns/core+UI 104; the all-104 control is correct | Deliberately induced mismatch; real UI/PTY comparison | Geometry mismatch causes cursor errors even with a properly marked colored prompt; field occurrence unverified | +| Clearing the output queue does not invalidate an in-flight checkpoint completion callback | Delayed-completion reproduction against the actual common queue | Obsolete completion can affect readiness; not proof of the Mac Vim cause | +| PTY resize and normal write/flush errors are discarded | Source inspection | Potential false success; real failure injection and field occurrence unverified | + +## A. Preserve complete command identity and provenance — P0 + +`crates/core/src/input.rs:156` resolves uncertain input from the cursor's single physical row. For wrapped commands that row is only a fragment. `strip_prompt` also uses cell-based `prompt_col` to slice `Vec`, breaking wide-character prompts. `session.rs:1766` assesses this result, but Enter submits the entire physical shell buffer. + +The reproduction used `printf '%s\n' > probe.out 'WRAP_BEGIN_…_END'`, with 200 synthetic padding characters. Without editing: approval pending, no file. After one LEFT: the returned command was only `…_END'`; the complete file write executed without approval. No deletion or production file was used. + +Recommended approach: distinguish reliable agent-owned tracking from uncertain input; track supported edits with a cursor; use cells, actual wrap metadata and confirmed prompt boundaries if reconstructing visible logical lines. Never elevate one row or stale tracking into a complete command. Do not inspect hidden text or offscreen history. If completeness is unavailable, recover that input before policy/approval/Enter rather than applying a blanket restriction to SSH or ordinary automatic commands. Review, policy and execution records must share command provenance. Simply ignoring dirty state is not a fix. + +Acceptance: wrapped LEFT/RIGHT/Backspace/Tab/history, wide/combining-character prompts and input beyond the viewport cannot turn a fragment into the policy command. Ordinary verified automatic commands still work. + +## B. Separate cancellation from physical input cleanup — P1 + +`session.rs:1985` sends Ctrl-U for denial/expiry then resets tracking. In default Bash editing, a suffix can remain to the right of the cursor. The same path also serves takeover/disconnect/release; policy Deny contains the same assumption. Human-input tracking already avoids treating Ctrl-U as proof of an empty buffer, but approval cancellation does not share that rule. + +Recommended approach: resolve cancellation immediately while retaining physical input state. In a confirmed shell editing context, safely cancel the complete line and wait for an observed empty boundary before resetting tracking. If cleanup is unverified, keep remaining input visible and explain that it needs clearing; do not append a new agent command. Do not inject generic Ctrl-C/editing sequences into editors or unknown foreground programs. Do not retain human input content. + +Acceptance: start/middle/end cursors and wrapped input across denial, expiry, release, disconnect and takeover leave no hidden suffix or automatic execution. Recovery must not create a permanent unexplained `input_pending` state. + +## C. Make PTY I/O success correspond to committed state — recommended hardening + +`session.rs:1092` changes the model and emits dimensions before discarding the PTY resize result. `write_pty` discards write/flush results too. Ordered requests are not proof of successful I/O. + +Recommended approach: return resize failure and do not commit failed dimensions; distinguish requested size, applied local PTY size and rendered size through content-free diagnostics. Do not report success or retry automatically after possibly partial input delivery. Checking local PTY size cannot certify remote geometry. + +In the geometry comparison, the same input started at zero-based `(row 3, col 12)`. After 30 LEFT keys the consistent control reached `(row 2, col 86)`, while the 80-column PTY case reached `(row 3, col 0)`. Core/xterm grid and cursor parity passed in both. Opening review set the PTY to 18 rows/104 columns in both. The mismatch was injected by the fixture; this does not establish that Conn creates it in the user's environment. + +Acceptance: failing PTY adapters and real SSH fixtures exercise errors and recovery; fixed-window review cycles, rapid opening/closing and input during long output preserve ordering. Avoid per-frame resize and arbitrary timing delays. + +## D. Fence renderer readiness by attachment generation — bounded hardening candidate + +`packages/ui/src/lib/terminalOutput.ts:18` still invokes an old in-flight completion after `clear()`. The reset completion in `Term.svelte` checks only `mounted` before setting `renderReady=true`. Fence these callbacks by the current attachment/checkpoint generation. + +Also check whether checkpoint waiting silently drops keys indefinitely. Show delayed recovery and explicit reconnect through the existing connection UI, without replaying uncertain input. Collect only focus/readiness/queue progress/delivery outcome metadata. This is a candidate for Vim investigation, not a proven cause of the reported freeze. + +Acceptance: delayed obsolete completions, disconnect/reconnect and reattachment during real Vim editing cannot restore readiness from an old checkpoint. Input becomes ready only after the current checkpoint is applied. + +## Scope and validation + +Recommend **A+B as the first fix bundle**. Design C around explicit failure outcomes and validate D's generation boundary separately. Include correctly and incorrectly marked colored prompts in validation; do not automatically rewrite user PS1 or strip colors. + +Do not append newlines to arbitrary terminal output, remove all motion, force 80 columns, or retry resize without checking outcomes. Do not equate Linux web tests with native Mac acceptance or merge the shell redraw and Vim input-freeze reports into one established cause. + +Final checks must cover intended cursor location, complete review text, policy outcome, actual synthetic output/file contents and cancellation leftovers in addition to core/xterm parity. Use local and disposable real SSH shells, plain/colored/wide/multiline prompts, editing and takeover, fixed-window review cycles, and native Mac Vim edit/save. The user's gateway and physical IME require separate evidence. + +The two diagnostic web scripts, synthetic screenshots and JSON from this investigation are preserved under `/tmp/conn-terminal-investigation/`. Their `PASS` output means capture/parser parity, not semantic correctness. Permanent regression tests must assert the intended correct behavior. + +References: [GNU Bash killing commands](https://www.gnu.org/s/bash/manual/html_node/Commands-For-Killing.html), [Linux PTY geometry and error returns](https://man7.org/linux/man-pages/man2/TIOCSWINSZ.2const.html), [separate Vim input investigation](vim-input-investigation.md). diff --git a/docs/vim-input-investigation.ko.md b/docs/vim-input-investigation.ko.md new file mode 100644 index 0000000..08a6330 --- /dev/null +++ b/docs/vim-input-investigation.ko.md @@ -0,0 +1,40 @@ +# 0.8.7 macOS Vim 입력 장애 조사 + +[English](vim-input-investigation.md) · [검사 실행법](browser-testing.ko.md#실제-vim-편집-회귀-검사) + +2026-09-23. **미해결: 사용자 macOS 네이티브 앱의 장애를 아직 재현하지 못했다.** 수정 완료나 재배포 근거로 사용하지 않는다. + +## 제보와 기준 + +사용자는 업데이트 전 같은 작업 환경에서 vi로 파일을 편집했지만, 최신 Conn에서는 Vim에서 Esc → i → 영문 입력에도 반응하지 않는다고 보고했다. 일반 셸도 함께 멈추는지, 화면만 정지하는지, 포커스가 유지되는지는 아직 구분되지 않았다. 실제 사용자 파일·인증 정보·원격 세션은 조회하거나 변경하지 않았다. + +검토 기준은 배포 태그 `v0.8.7`과 `v0.8.6`이다. 로컬 검사 체크아웃은 `05b6214`이며 v0.8.7에 포함된 소스다. 제품 코드에는 추측에 따른 수정을 하지 않았다. + +## 코드에서 확인한 것 + +- `Session::human_input`은 사람 입력을 PTY로 전달한다. 새 `HumanInput.pending`은 사람의 키 입력을 직접 거부하는 조건이 아니다. +- 공통 Term은 `renderReady` 또는 `backendOnline`이 거짓이면 입력을 전달하지 않는다. 이 준비 상태와 네이티브 출력 구독·체크포인트 전달 경로는 추가 조사 대상이다. 조건이 존재한다는 사실만으로 장애 원인을 확정하지 않는다. +- 사람 입력·터미널 응답·크기 변경은 공통 클라이언트의 세션별 큐를 통과한다. 실제 Mac에서 키 이벤트 생성, 큐 제출, 네이티브 호출 완료, PTY 출력 중 어느 단계가 멈추는지 구분해야 한다. +- 기존 입력·렌더링 검사에는 실제 Vim 실행·편집·저장이 없었다. 긴 셸 명령 및 화면 파서 검사를 Vim 편집 검증으로 해석하면 안 된다. + +## 이번 실행 결과 + +실제 프로덕션 UI와 Rust 웹 호스트, 실제 PTY, 실제 vi를 사용했다. 파일과 설정은 임시 디렉터리, SSH 서버는 폐기 가능한 루프백 OpenSSH다. Linux WebKit은 macOS 네이티브 WebKit과 같은 검증 환경이 아니다. + +| 호스트 / 브라우저 | 셸 | 편집 시나리오 | +| --- | --- | --- | +| Linux / Chromium | 로컬 Bash | 5 통과 | +| Linux / WebKit | 로컬 Bash | 5 통과, ResizeObserver 경고 1회 | +| Linux / Chromium | 실제 루프백 SSH | 5 통과 | +| Linux / WebKit | 복합 명령으로 진입한 실제 루프백 SSH | 5 통과, ResizeObserver 경고 1회 | + +각 실행은 비공유 편집, 편집 중 화면 재연결, 공유·제어 요청 후 사람 개입, 공유 상태의 편집 중 재연결, 100줄 파일의 마지막 행에서 창 크기 변경 후 편집을 검사한다. 일반 모드·삽입 모드 전환, 커서 이동·삭제·입력·저장과 저장 내용까지 확인한다. Vim 진입 후 포커스를 테스트가 강제로 복구하지 않는다. + +`ResizeObserver loop completed with undelivered notifications.` 경고는 초기 엄격 실행에서 테스트를 실패시켰다. 편집·저장 검사는 그 실행에서도 통과했다. 정식 검사는 이 이미 알려진 경고를 별도 집계·출력하고 `runtime.json`에 보존하며 다른 브라우저 오류는 실패 처리한다. 경고가 무해하거나 이번 장애의 원인이라는 결론은 내리지 않았다. + +## 남은 확인 + +1. 장애가 난 Mac의 실제 앱에서 일반 셸과 Vim 중 어디서 입력이 끊기는지 구분한다. +2. 내용 없이 포커스·렌더러 준비 상태·입력 요청 완료 여부·출력 진행 여부를 확인한다. 원시 키, 편집 버퍼, `.env` 내용은 수집하지 않는다. +3. 같은 Mac/SSH 경로에서 이전 릴리스와 비교하고 재현 조건을 고정한다. 사용자 작업을 종료하거나 미저장 파일을 버리지 않는다. +4. 재현을 실패하는 회귀 검사로 만든 뒤 수정하고 네이티브 재검증한다. 이번 Linux 결과를 Mac 수정 완료나 배포 승인으로 대신하지 않는다. diff --git a/docs/vim-input-investigation.md b/docs/vim-input-investigation.md new file mode 100644 index 0000000..d84a3df --- /dev/null +++ b/docs/vim-input-investigation.md @@ -0,0 +1,40 @@ +# 0.8.7 macOS Vim input investigation + +[한국어](vim-input-investigation.ko.md) · [Run the test](browser-testing.md#actual-vim-editing-regression) + +2026-09-23. **Unresolved: the reported native macOS failure has not been reproduced.** This report is not evidence of a fix or a reason to publish another release. + +## Report and baseline + +The user could edit files in vi before the update, but reports no response to Escape → i → English text in the latest Conn. It is not yet established whether ordinary shell input also stops, output alone freezes, or keyboard focus is lost. No user files, authentication data or live remote sessions were inspected or modified. + +Code review compared `v0.8.6` and `v0.8.7`. Local tests used checkout `05b6214`, included in v0.8.7. No speculative product-code change was made. + +## Source findings + +- `Session::human_input` forwards human bytes to the PTY. The new `HumanInput.pending` flag is not a direct gate on human typing. +- The common Term component drops input when `renderReady` or `backendOnline` is false. Readiness, native output subscription and checkpoint delivery need further investigation; the existence of this gate does not establish causation. +- Input, terminal replies and resize operations share a session FIFO. On the affected Mac, distinguish key event generation, queue submission, native call completion and PTY output progress. +- Previous input/rendering tests did not execute, edit and save with real Vim. Long shell input and parser tests did not establish editor compatibility. + +## Executed tests + +Tests used the production UI, real Rust web host, actual PTYs and real vi. Files/settings were disposable; SSH used disposable loopback OpenSSH. Linux WebKit is not native macOS WebKit evidence. + +| Host / browser | Shell | Editing scenarios | +| --- | --- | --- | +| Linux / Chromium | Local Bash | 5 passed | +| Linux / WebKit | Local Bash | 5 passed; 1 ResizeObserver warning | +| Linux / Chromium | Real loopback SSH | 5 passed | +| Linux / WebKit | Real loopback SSH entered through a compound command | 5 passed; 1 ResizeObserver warning | + +Each run covers private editing, reattachment during editing, shared human takeover after control approval, shared reattachment, and last-row editing in a 100-line buffer after resizing. Assertions include normal/insert mode transitions, cursor editing, saving and saved content. The test does not forcibly restore focus after launching Vim. + +`ResizeObserver loop completed with undelivered notifications.` initially failed strict runs, although their editing/saving checks passed. The final test counts and reports this previously observed warning separately in `runtime.json`; other browser errors still fail. Neither harmlessness nor a causal link to the incident is established. + +## Remaining verification + +1. Establish whether ordinary shell input or only Vim stops in the affected native app. +2. Inspect content-free focus/readiness, input completion and output progress. Do not collect raw keys, editor buffers or `.env` contents. +3. Compare the previous release on the same Mac/SSH path and fix the reproduction conditions without closing live work or discarding unsaved files. +4. Turn the reproduction into a failing regression test, fix it and verify natively. The Linux results do not substitute for native Mac acceptance or a release decision. diff --git a/frontends/tauri/package.json b/frontends/tauri/package.json index f75452e..ea6647b 100644 --- a/frontends/tauri/package.json +++ b/frontends/tauri/package.json @@ -1,7 +1,7 @@ { "name": "conn-desktop", "private": true, - "version": "0.8.7", + "version": "0.8.8", "type": "module", "scripts": { "dev": "vite", diff --git a/frontends/tauri/src-tauri/tauri.conf.json b/frontends/tauri/src-tauri/tauri.conf.json index f4fa798..a738b4c 100644 --- a/frontends/tauri/src-tauri/tauri.conf.json +++ b/frontends/tauri/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Conn", - "version": "0.8.7", + "version": "0.8.8", "identifier": "dev.eggp.conn", "build": { "beforeDevCommand": "npm run dev", diff --git a/frontends/web/package.json b/frontends/web/package.json index 3ad7f39..1ee4b51 100644 --- a/frontends/web/package.json +++ b/frontends/web/package.json @@ -1,6 +1,6 @@ { "name": "@conn/web", - "version": "0.8.7", + "version": "0.8.8", "private": true, "type": "module", "scripts": { diff --git a/package-lock.json b/package-lock.json index 7045636..ac1e369 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ }, "frontends/tauri": { "name": "conn-desktop", - "version": "0.8.7", + "version": "0.8.8", "dependencies": { "@conn/ui": "*", "@tauri-apps/api": "^2" @@ -1860,7 +1860,7 @@ }, "frontends/web": { "name": "@conn/web", - "version": "0.8.7", + "version": "0.8.8", "dependencies": { "@conn/ui": "*", "svelte": "^5.57.0" @@ -11524,7 +11524,7 @@ }, "packages/ui": { "name": "@conn/ui", - "version": "0.8.7", + "version": "0.8.8", "dependencies": { "@conn/brand": "*", "@conn/themes": "*", diff --git a/packages/ui/package.json b/packages/ui/package.json index 97e2dd1..6671ba3 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@conn/ui", - "version": "0.8.7", + "version": "0.8.8", "private": true, "type": "module", "exports": { diff --git a/packages/ui/src/components/Term.svelte b/packages/ui/src/components/Term.svelte index ff13269..e725938 100644 --- a/packages/ui/src/components/Term.svelte +++ b/packages/ui/src/components/Term.svelte @@ -21,6 +21,13 @@ let fit: FitAddon; let requestFit = () => {}; let renderReady = $state(false); + let syncSlow = $state(false); + let recover = () => {}; + $effect(() => { + if (renderReady || !active || !st.backendOnline) { syncSlow = false; return; } + const timer = app.timeout(() => { syncSlow = true; }, 3000); + return () => app.clearTimeout(timer); + }); const t = $derived(tab(session)!); const active = $derived(st.active === session); export function focus() { term?.focus(); } @@ -65,7 +72,7 @@ // stream so shell redraws and size changes reach both parsers in one order. const sizes = terminalResizeQueue( ({ rows, cols }) => cmd("resize", { session, rows, cols }), - (error) => console.error("Terminal resize failed", error), + (error) => { console.error("Terminal resize failed", error); app.toast(app.t("terminal.resize_failed"), "warn"); }, ); requestFit = () => { if (!renderReady || !st.backendOnline) return; @@ -147,6 +154,10 @@ if (p.reset) renderReady = false; output.push({ size: p.size, data: b64ToBytes(p.data), reset: p.reset, applied: p.reset ? () => { if (mounted) { renderReady = true; sizes.invalidate(); requestFit(); } } : undefined }); }); + recover = () => { + renderReady = false; output.clear(); sizes.invalidate(); + void cmd('attach_output', {session}).catch(error => app.toast(String(error), 'warn')); + }; let attachmentRequested = false; const attach = () => { if (!mounted || attachmentRequested) return; @@ -162,7 +173,7 @@ else attach(); }); Promise.all([un, sync, connection]).then(attach).catch(() => {}); - return () => { mounted = false; sizes.dispose(); output.dispose(); requestFit = () => {}; inputListener.dispose(); privateClipboard.dispose(); motion?.cancel(); ro.disconnect(); for (const listener of [un, sync, connection]) void listener.then(f => f()); term.dispose(); }; + return () => { mounted = false; sizes.dispose(); output.dispose(); requestFit = () => {}; recover = () => {}; inputListener.dispose(); privateClipboard.dispose(); motion?.cancel(); ro.disconnect(); for (const listener of [un, sync, connection]) void listener.then(f => f()); term.dispose(); }; }); $effect(() => { @@ -173,7 +184,12 @@ +{#if active && syncSlow && st.backendOnline} +
{app.t('terminal.syncing')}
+{/if} +