diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index ae37ecd..384bb7a 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.4" + "version": "0.8.5" } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index d0dbe48..9ed4419 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ ## Unreleased +## 0.8.5 — Preview · 2026-09-22 + +- Restore policy-based Autopilot in POSIX SSH: ordinary commands run, risky commands request approval, and deny rules remain blocked. Remote paths are never inspected as host files. +- Make **Allow this session** work for SSH confirm rules and publish the allowance immediately. Programs requiring individual review explain that requirement instead of showing an inert button. The control indicator explains the selected collaboration mode. +- Stop resizing the underlying terminal when opening the modal settings sheet. The obsolete side-panel width animation repeatedly resized SSH line editors and corrupted long input/cursor placement. +- Keep approval actions visible while long command details scroll inside the common decision card. + +한국어: POSIX SSH의 Autopilot에서 일반 명령은 자동 실행하고 위험 명령은 승인을 받도록 수정했습니다. 세션 허용은 해당 확인 규칙에 적용하고 즉시 표시하며, 명령별 검토가 필요한 경우에는 이유를 안내합니다. 모달 설정 뒤에서 남아 있던 터미널 너비 변경을 제거해 재현된 긴 입력·커서 깨짐을 수정했습니다. 긴 명령의 승인 내용은 카드 안에서 스크롤하고 승인 버튼은 계속 표시합니다. + +Validation: 258 Rust tests, 48 frontend tests, 85 core/xterm comparisons, real SSH browser regressions and the 12-stage collaboration harness passed. On Apple Silicon macOS 27.0, the same product code was also exercised in an ad-hoc signed native debug app with real WebKit, Tauri IPC and loopback SSH: long input, cursor editing, approval/session allowances, takeover and original-shell return passed. Release artifact signing/notarization and installation checks are separate. + +Remaining coverage: physical-keyboard Korean IME composition and the user's exact SSH setup remain unverified. WebKit emitted ResizeObserver warnings without a persistent mismatch in the checked steps. Other macOS versions, Windows interactive GUI and full installed-app upgrades remain open. [Validation report](docs/ssh-input-review-results.md). + +한국어: Rust 258개·프런트엔드 48개·화면 비교 85회·실제 SSH 브라우저 회귀·협업 하네스 12단계를 통과했습니다. Apple Silicon macOS 27.0의 실제 WebKit·Tauri 디버그 앱과 SSH에서 긴 입력·커서 편집·승인·세션 허용·제어권 회수·원래 셸 복귀도 확인했습니다. 배포 파일 서명·공증과 설치 검사는 별도입니다. 실제 키보드 한글 IME 조합과 사용자의 정확한 SSH 환경은 미검증이며, ResizeObserver 경고가 관찰됐습니다. 다른 macOS 버전, Windows 대화형 UI와 설치된 앱의 전체 업그레이드 검증은 남아 있습니다. [검증 보고서](docs/ssh-input-review-results.ko.md). + ## 0.8.4 — Preview · 2026-09-21 - Keep pending requests, active control and recent agent activity visible across tabs. Clicking a notice opens the exact terminal and request; opening or switching an agent's tab preserves the person's current selection. diff --git a/Cargo.lock b/Cargo.lock index 5d91a03..f90e332 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -696,7 +696,7 @@ dependencies = [ [[package]] name = "conn" -version = "0.8.4" +version = "0.8.5" dependencies = [ "clap", "conn-core", @@ -706,7 +706,7 @@ dependencies = [ [[package]] name = "conn-browser-harness" -version = "0.8.4" +version = "0.8.5" dependencies = [ "axum", "conn-core", @@ -719,7 +719,7 @@ dependencies = [ [[package]] name = "conn-core" -version = "0.8.4" +version = "0.8.5" dependencies = [ "chrono", "dirs 5.0.1", @@ -742,7 +742,7 @@ dependencies = [ [[package]] name = "conn-desktop" -version = "0.8.4" +version = "0.8.5" dependencies = [ "base64 0.22.1", "cc", @@ -764,7 +764,7 @@ dependencies = [ [[package]] name = "conn-frontend" -version = "0.8.4" +version = "0.8.5" dependencies = [ "base64 0.22.1", "conn-core", diff --git a/Cargo.toml b/Cargo.toml index 33c8695..0e139d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,14 +6,14 @@ members = ["crates/core", "crates/cli", "crates/frontend", "crates/browser-harne default-members = ["crates/core", "crates/cli", "crates/frontend", "crates/browser-harness"] [workspace.package] -version = "0.8.4" +version = "0.8.5" edition = "2021" license = "MIT" repository = "https://github.com/eggp-dev/conn" [workspace.dependencies] -conn-core = { path = "crates/core", version = "0.8.4" } -conn-frontend = { path = "crates/frontend", version = "0.8.4" } +conn-core = { path = "crates/core", version = "0.8.5" } +conn-frontend = { path = "crates/frontend", version = "0.8.5" } 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 4fdc5ea..5a72de1 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.4 프리뷰 | +| 운영체제 | v0.8.5 프리뷰 | |---|---| -| macOS · Apple Silicon | [Mac용 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.8.4/conn-v0.8.4-aarch64-apple-darwin-desktop.dmg) | -| Windows · x64 | [설치 파일 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.8.4/conn-v0.8.4-x86_64-pc-windows-msvc-setup.exe) | -| Ubuntu · x64 | [.deb 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.8.4/conn-v0.8.4-x86_64-unknown-linux-gnu-desktop.deb) · [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.4/conn-v0.8.4-x86_64-unknown-linux-gnu-desktop.AppImage) | +| macOS · Apple Silicon | [Mac용 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.8.5/conn-v0.8.5-aarch64-apple-darwin-desktop.dmg) | +| Windows · x64 | [설치 파일 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.8.5/conn-v0.8.5-x86_64-pc-windows-msvc-setup.exe) | +| Ubuntu · x64 | [.deb 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.8.5/conn-v0.8.5-x86_64-unknown-linux-gnu-desktop.deb) · [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.5/conn-v0.8.5-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.4) +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.5) ## 처음 5분 diff --git a/README.md b/README.md index 30bf664..e814df3 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.4 preview | +| Platform | v0.8.5 preview | |---|---| -| macOS · Apple Silicon | [Download for Mac](https://github.com/eggp-dev/conn/releases/download/v0.8.4/conn-v0.8.4-aarch64-apple-darwin-desktop.dmg) | -| Windows · x64 | [Download installer](https://github.com/eggp-dev/conn/releases/download/v0.8.4/conn-v0.8.4-x86_64-pc-windows-msvc-setup.exe) | -| Ubuntu · x64 | [Download .deb](https://github.com/eggp-dev/conn/releases/download/v0.8.4/conn-v0.8.4-x86_64-unknown-linux-gnu-desktop.deb) · [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.4/conn-v0.8.4-x86_64-unknown-linux-gnu-desktop.AppImage) | +| macOS · Apple Silicon | [Download for Mac](https://github.com/eggp-dev/conn/releases/download/v0.8.5/conn-v0.8.5-aarch64-apple-darwin-desktop.dmg) | +| Windows · x64 | [Download installer](https://github.com/eggp-dev/conn/releases/download/v0.8.5/conn-v0.8.5-x86_64-pc-windows-msvc-setup.exe) | +| Ubuntu · x64 | [Download .deb](https://github.com/eggp-dev/conn/releases/download/v0.8.5/conn-v0.8.5-x86_64-unknown-linux-gnu-desktop.deb) · [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.5/conn-v0.8.5-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.4) +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.5) ## First five minutes diff --git a/crates/core/src/engine.rs b/crates/core/src/engine.rs index 777ce6d..f03656c 100644 --- a/crates/core/src/engine.rs +++ b/crates/core/src/engine.rs @@ -186,6 +186,7 @@ impl Engine { session.lock().set_output_frame_sink(cfg.output_frame); session.lock().set_execution_profile(profile); + session.lock().set_ssh_transport(std::path::Path::new(&plan.program).file_stem().is_some_and(|name| name == "ssh")); #[cfg(unix)] let integration = Arc::new(parking_lot::Mutex::new(integration)); #[cfg(unix)] diff --git a/crates/core/src/session.rs b/crates/core/src/session.rs index 1867e86..bf1d9cb 100644 --- a/crates/core/src/session.rs +++ b/crates/core/src/session.rs @@ -430,6 +430,8 @@ pub struct Session { surface_id: String, external_writer_active: bool, execution_profile: Option, + ssh_transport: bool, + ssh_foreground_sequence: Option, authority: Authority, policy: PolicyStore, approvals: ApprovalQueue, @@ -518,6 +520,8 @@ impl Session { fn with_origin(cfg: SessionConfig, external_private: bool) -> Self { Self { external_origin: external_private, + ssh_transport: false, + ssh_foreground_sequence: None, participation: Participation::new(external_private), participation_listener: None, surface_generation: 1, @@ -586,10 +590,24 @@ impl Session { } pub fn review_required(&self) -> bool { + if self.remote_policy() { return false; } self.external_origin || self.execution_profile.as_ref().is_some_and(|profile| profile.needs_review()) + || self.ssh_foreground_sequence.is_some() || (self.shell_integration.state == "active" && (!self.shell_prompt_confirmed || self.shell_submission_inflight)) } + /// SSH changes where commands and paths are interpreted, not the selected + /// collaboration mode. Use POSIX policy without inspecting the host filesystem. + fn remote_policy(&self) -> bool { + self.execution_profile.as_ref().is_some_and(|profile| { + profile.shell == crate::backend::ShellKind::Posix + && (profile.backend == crate::backend::BackendKind::Ssh || self.ssh_transport + || (self.shell_integration.state == "active" && self.ssh_foreground_sequence.is_some())) + }) + } + + pub(crate) fn set_ssh_transport(&mut self, ssh: bool) { self.ssh_transport = ssh; } + pub fn completion_prompt_ready(&self) -> bool { self.participation.shared && self.process_alive && self.shell_integration.state == "active" && self.shell_command.is_none() && self.shell_prompt_confirmed && !self.shell_submission_inflight @@ -641,8 +659,9 @@ impl Session { if !self.process_alive { return; } // Lifecycle sequence alone is retained privately. It identifies the outer // shell's foreground program without retaining its command or arguments. - let matching_end = kind == "end" && self.shell_foreground_sequence.is_some() - && self.shell_foreground_sequence == text.parse::().ok(); + let ended_sequence = text.parse::().ok(); + let matching_end = kind == "end" && ended_sequence.is_some() + && (self.shell_foreground_sequence == ended_sequence || self.ssh_foreground_sequence == ended_sequence); let record_start = self.participation.shared && self.shell_recording_ready && self.shell_recording_armed; match kind { "ready" => { @@ -654,18 +673,24 @@ impl Session { } "start" => { self.shell_foreground_sequence = Some(sequence); + // Retain only transport identity, including during a private login. + // A word containing "ssh" (or a compound script) is not an SSH launch. + let segments = crate::analysis::analyse_line(text); + self.ssh_foreground_sequence = (segments.len() == 1 && segments[0].command.as_deref() == Some("ssh")).then_some(sequence); self.shell_prompt_confirmed = false; self.shell_recording_ready = false; self.shell_recording_armed = false; } "end" if matching_end => { self.shell_foreground_sequence = None; + self.ssh_foreground_sequence = None; self.shell_submission_inflight = false; self.shell_prompt_confirmed = true; self.shell_recording_ready = self.participation.shared && self.shell_integration.state == "active"; self.shell_recording_armed = false; } "gap" | "unavailable" => { + self.shell_integration.state = "unavailable".into(); self.shell_prompt_confirmed = false; self.shell_recording_ready = false; self.shell_recording_armed = false; @@ -794,6 +819,7 @@ impl Session { } fn analyse(&self, cmd: &str) -> crate::policy::LineAnalysis { + if self.remote_policy() { return self.policy.analyse(cmd, None, None); } if self.review_required() { let shell = self.execution_profile.as_ref().filter(|p| p.needs_review()).map_or(crate::backend::ShellKind::Custom, |p| p.shell); return self.policy.analyse_external(cmd, shell); @@ -1853,6 +1879,7 @@ impl Session { 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 }) } @@ -2023,3 +2050,71 @@ impl Session { &self.screen } } + + +#[cfg(test)] +mod ssh_policy_tests { + use super::*; + use crate::backend::{Profile, ShellKind}; + + fn session() -> Session { + let mut s = Session::new(SessionConfig { + rows: 24, cols: 80, audit: Audit::null(), + policy: PolicyStore::from_policy(crate::policy::Policy::parse(crate::policy::EXAMPLE_POLICY).unwrap()), + pty_writer: Box::new(Vec::::new()), master: None, + pacing: Pacing::default(), shell_pid: None, + }); + s.set_execution_profile(Profile::local("local".into(), "/bin/bash".into())); + s.set_cwd_override(Some(std::env::temp_dir())); + s.shell_event(1, "ready", "bash", "", ""); + s + } + + #[test] + fn nested_ssh_uses_remote_rules_until_its_matching_exit() { + let mut s = session(); + s.set_shared(false).unwrap(); + s.shell_event(2, "start", "/usr/bin/ssh example-host", "/private", ""); + s.set_shared(true).unwrap(); + assert!(!s.review_required()); + let analysis = s.analyse_line("rm file"); + assert!(analysis.cwd.is_none()); + assert!(matches!(analysis.decision, PolicyDecision::Confirm { .. })); + s.shell_event(3, "end", "99", "", "0"); + assert!(s.remote_policy(), "an unrelated end cannot change the target"); + s.shell_event(4, "end", "2", "", "0"); + assert!(!s.remote_policy()); + assert!(s.analyse_line("printf local").cwd.is_some()); + } + + #[test] + fn lost_ssh_lifecycle_requires_review_without_using_local_paths() { + let mut s = session(); + s.shell_event(2, "start", "ssh example-host", "", ""); + s.shell_event(4, "gap", "", "", ""); + assert!(s.review_required()); + let analysis = s.analyse_line("printf remote"); + assert!(analysis.cwd.is_none()); + assert!(matches!(analysis.decision, PolicyDecision::Confirm { .. })); + s.shell_event(5, "end", "2", "", "0"); + assert!(!s.remote_policy()); + assert!(!s.review_required()); + } + + #[test] + fn other_foreground_programs_and_non_posix_ssh_still_require_review() { + for command in ["printf ssh", "ssh host; printf local", "sh", "vim file"] { + let mut s = session(); + s.shell_event(2, "start", command, "", ""); + assert!(!s.remote_policy(), "{command}"); + assert!(s.review_required(), "{command}"); + } + let mut s = session(); + let mut profile = Profile::local("remote".into(), "pwsh".into()); + profile.backend = crate::backend::BackendKind::Ssh; + profile.shell = ShellKind::PowerShell; + s.set_execution_profile(profile); + s.set_ssh_transport(true); + assert!(s.review_required()); + } +} diff --git a/crates/core/tests/control_regressions.rs b/crates/core/tests/control_regressions.rs index faebe71..69ca254 100644 --- a/crates/core/tests/control_regressions.rs +++ b/crates/core/tests/control_regressions.rs @@ -4,7 +4,7 @@ mod common; use common::{Harness, SessionExt}; use conn_core::affordance::Actor; use conn_core::approval::{ApprovalState, Decision}; -use conn_core::backend::{Profile, ShellKind}; +use conn_core::backend::{BackendKind, Profile, ShellKind}; use conn_core::session::{AgentMode, ConnKind, ControlOutcome, KeyResult, ProposalState, ServerEvent, SessionError}; use serde_json::json; @@ -124,6 +124,35 @@ fn review_required_allow_session_rejection_keeps_request_pending() { assert_eq!(String::from_utf8_lossy(&h.pty.lock().unwrap()), "Write-Output hello\r"); } +#[test] +fn ssh_autopilot_uses_risk_rules_and_scoped_session_allow_without_local_paths() { + use conn_core::policy::Decision as PolicyDecision; + let mut h = Harness::new(); + let mut profile = Profile::local("ssh".into(), "/bin/bash".into()); + profile.backend = BackendKind::Ssh; + h.session.set_execution_profile(profile); + h.session.set_cwd_override(Some(std::env::temp_dir())); + h.agent(1, "ssh-agent"); + let events = h.frontend("ui"); + h.session.agent_request_control(1).unwrap(); + assert!(!h.session.review_required()); + h.session.agent_type(1, "printf ordinary").unwrap(); + assert!(matches!(h.session.agent_send_key(1, "ENTER").unwrap(), KeyResult::Executed { .. })); + let analysis = h.session.analyse_line("rm missing-file"); + assert!(analysis.cwd.is_none()); + assert!(analysis.segments.iter().all(|segment| segment.targets.is_empty())); + assert!(matches!(analysis.decision, PolicyDecision::Confirm { .. })); + h.session.agent_type(1, "rm missing-file").unwrap(); + let KeyResult::Pending { approval_id, .. } = h.session.agent_send_key(1, "ENTER").unwrap() else { panic!() }; + h.session.resolve_approval(&approval_id, Decision::AllowSession, "frontend").unwrap(); + assert!(events.lock().unwrap().iter().any(|event| matches!(event, ServerEvent::SessionAllowsChanged { allows } if allows == &["delete files"]))); + assert!(matches!(h.session.analyse_line("rm another-file").decision, PolicyDecision::Allow)); + assert!(matches!(h.session.analyse_line("sudo id").decision, PolicyDecision::Confirm { .. })); + assert!(matches!(h.session.analyse_line("rm -rf /").decision, PolicyDecision::Deny { .. })); + assert!(matches!(h.session.analyse_line("echo first; sudo id").decision, PolicyDecision::Deny { .. })); + assert!(matches!(h.session.analyse_line("python -c 'print(1)'").decision, PolicyDecision::Confirm { .. })); +} + #[test] fn modes_are_available_in_presented_snapshot_and_changes_reach_agents() { let mut h=Harness::new();let events=h.agent(1,"claude"); diff --git a/crates/core/tests/shell_integration.rs b/crates/core/tests/shell_integration.rs index dcd6096..eda21fe 100644 --- a/crates/core/tests/shell_integration.rs +++ b/crates/core/tests/shell_integration.rs @@ -320,8 +320,8 @@ fn nested_foreground_commands_require_review_across_a_sharing_boundary() { let h=Shell::new("/bin/bash",false);let session=h.engine.session(); assert!(!session.lock().review_required()); assert!(matches!(session.lock().analyse_line("printf local").decision,Decision::Allow)); - // SSH, editors and nested shells are all foreground programs from the outer - // shell's perspective. Do not apply this machine's filesystem assumptions. + // Unclassified programs, including editors and nested shells, still need + // review. Do not apply this machine's filesystem assumptions. h.send("/bin/sh");h.until(||h.starts().len()==1); { let mut s=session.lock(); diff --git a/crates/frontend/src/automation/tests.rs b/crates/frontend/src/automation/tests.rs index 6681d5a..7545929 100644 --- a/crates/frontend/src/automation/tests.rs +++ b/crates/frontend/src/automation/tests.rs @@ -409,6 +409,19 @@ fn real_ssh_login_injected_by_a_launcher_stays_secret_after_sharing() { } let audit=std::fs::read_to_string(h.state.config_dir.join("audit.jsonl")).unwrap(); assert!(audit.contains("sharing_started")&&!audit.contains("fixture@127.0.0.1"),"startup arguments are not backfilled"); + assert_eq!(shared["reviewRequired"],false,"a direct POSIX SSH launch uses command policy after sharing"); + h.invoke_in_window("main","set_mode",json!({"session":id,"mode":"autopilot"})).unwrap(); + h.invoke_in_window("main","set_control_gate",json!({"session":id,"ask":false})).unwrap(); + h.invoke_in_window("main","set_pacing",json!({"session":id,"patch":{"enterGraceMs":0}})).unwrap(); + agent.call("request_control",json!({"session":id,"reason":"Print a synthetic remote marker"})).unwrap(); + agent.call("type",json!({"session":id,"text":"printf 'AGENT_REMOTE_%s\\n' 42"})).unwrap(); + let executed=agent.call("send_key",json!({"session":id,"key":"ENTER","intent":"Print a synthetic remote marker"})).unwrap(); + assert_eq!(executed["status"],"executed"); + wait_for(||owner_output(&events,&id,"main").contains("AGENT_REMOTE_42")); + agent.call("type",json!({"session":id,"text":"sudo id"})).unwrap(); + let pending=agent.call("send_key",json!({"session":id,"key":"ENTER","intent":"Verify privilege review without executing"})).unwrap(); + assert_eq!(pending["status"],"pending"); + // Stopping sharing cancels the pending command; no privileged command is run. h.invoke_in_window("main","set_sharing",json!({"session":id,"shared":false,"connectionIds":[]})).unwrap(); assert!(agent.call("snapshot",json!({"session":id})).is_err()); h.shutdown(); diff --git a/docs/backends.md b/docs/backends.md index f48b2fd..ac90293 100644 --- a/docs/backends.md +++ b/docs/backends.md @@ -141,19 +141,25 @@ events on Windows. The desktop shares the same Engine with xterm.js rendering. Closing a tab terminates its child process; it does not assume every shell accepts Ctrl-D as an exit command. -Local POSIX shells retain existing command and filesystem policy analysis. Every -remote or non-POSIX profile requires human review for each agent command, even -when the normal policy default is allow. Session-wide approvals cannot remove -that requirement. Deny matches still block execution; compound commands are -rejected when command isolation is enabled. Remote cwd and file targets are not -invented from the host filesystem. +Local POSIX shells retain command and filesystem policy analysis. POSIX SSH +sessions apply command policy without host filesystem inspection: in Autopilot, +ordinary commands run and risky commands request approval. **Allow this session** +lifts only the matching confirm label; deny rules remain in force. Co-pilot still +requires acceptance of each proposal. Remote cwd, home and file targets are unknown. + +Other remote backends and non-POSIX profiles require review for every agent command; +session allowances cannot remove that requirement. Command isolation remains enabled +according to policy. The non-POSIX parser recognizes simple commands and dialect quoting for review; it does not expand PowerShell aliases, functions, nested scripts, or certify arbitrary syntax. As with existing policy, this is a guard against mistakes, not a security sandbox. Choosing a shell kind must match the executable. -Starting a nested SSH client manually inside a local POSIX tab does not change -that tab's profile; use an SSH profile for the remote review boundary. +A direct SSH launch or a single `ssh` command identified by local Bash/Zsh +integration uses remote command policy until that SSH process exits. This does +not change the tab's profile, install remote hooks, or infer remote authentication. +Without that integration, use an SSH profile to identify the remote execution target. +An unclassified foreground program or lost SSH lifecycle requires individual review. The existing line-delimited JSON protocol uses Unix sockets on macOS/Linux and local Windows named pipes with an owner-only DACL and remote clients rejected. diff --git a/docs/browser-testing.md b/docs/browser-testing.md index 9f5666b..983bf90 100644 --- a/docs/browser-testing.md +++ b/docs/browser-testing.md @@ -93,3 +93,23 @@ Screenshots, video, state and grid comparisons are written under the printed tem soft wraps, scroll, height/width changes and alternate-screen transitions. The browser test separately compares actual rendered rows against agent snapshots. Neither test proves every terminal program, Unicode sequence, SSH connection or native window-manager behavior. + +## SSH input, cursor and approval regression + +After building the Rust harness, run `npm run test:input:harness` from +`frontends/tauri`. It uses ports 1471/1473 and writes screenshots plus rendered-grid +and cursor comparisons to the printed temporary directory (`CONN_INPUT_REVIEW_OUTPUT` +overrides it). The default uses a local Bash PTY. + +To exercise real SSH, set `CONN_AUTH_FIXTURE_RUNTIME` to the disposable +`terminal-auth-fixtures/.runtime` directory before running it. The fixture must +already be listening on `127.0.0.1:22222`; the test accepts only its synthetic +password and pinned known-hosts file. It authenticates through the same visible PTY +and uses remote Bash with `LC_ALL=C.UTF-8` and `TERM=xterm-256color`. + +Coverage includes long ASCII/Unicode input, settings modal open/close without PTY +resizing, a long risky command held for approval while resizing the window, the +session-allow button and its limited scope, a separate risk still requesting approval, +policy denial, human cursor movement/insertion, takeover, and return from SSH to the +original local shell. No privileged command is approved. This is Linux Chromium +coverage of the production UI and real Rust/PTY/SSH path, not macOS WebKit acceptance. diff --git a/docs/faq.ko.md b/docs/faq.ko.md index c055504..76310f2 100644 --- a/docs/faq.ko.md +++ b/docs/faq.ko.md @@ -48,7 +48,7 @@ v0.8.0부터 그렇습니다. Conn을 최소화했거나, 다른 앱 뒤에 있 ## SSH에 접속한 뒤에도 협업할 수 있나요? -일반 세션의 화면·제어 권한 범위에서 계속할 수 있습니다. Conn은 공유 터미널 안의 SSH 클라이언트를 조작합니다. 로컬 셸 통합은 바깥쪽 `ssh` 명령을 기록하며, 원격 훅을 설치하거나 원격 셸에서 사람이 실행한 명령을 자동 기록하지 않습니다. 원격 프로필에서는 명령 검토가 필요합니다. [프로필과 실행 범위](backends.md) +일반 세션의 화면·제어 권한 범위에서 계속할 수 있습니다. Conn은 공유 터미널 안의 SSH 클라이언트를 조작합니다. 로컬 셸 통합은 바깥쪽 `ssh` 명령을 기록하며, 원격 훅을 설치하거나 원격 셸에서 사람이 실행한 명령을 자동 기록하지 않습니다. POSIX SSH의 Autopilot에서는 일반 명령을 실행하고 정책에서 지정한 위험 명령은 승인을 기다립니다. 세션 허용은 해당 확인 규칙에만 적용되며, 원격 파일 경로를 이 컴퓨터에서 검사하지 않습니다. [프로필과 실행 범위](backends.md) ## 외부 자동화 세션도 에이전트와 공유하나요? diff --git a/docs/faq.md b/docs/faq.md index ebc974a..cc3b2e7 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -48,7 +48,7 @@ The timeline is not a screen recording or a backup of running processes. Command ## Does collaboration still work over SSH? -Yes, within the ordinary session's existing screen and control permissions. Conn drives the SSH client in the shared terminal. Local shell integration records the outer `ssh` command; it does not install remote hooks or automatically record the remote shell's human commands. Remote profiles require command review. [Profiles and boundaries](backends.md) +Yes, within the ordinary session's existing screen and control permissions. Conn drives the SSH client in the shared terminal. Local shell integration records the outer `ssh` command; it does not install remote hooks or automatically record the remote shell's human commands. In POSIX SSH, Autopilot runs ordinary commands and asks for commands flagged by policy. Allow this session applies only to the matching confirm rule. Conn does not inspect remote filesystem targets. [Profiles and boundaries](backends.md) ## Are external automation sessions shared too? diff --git a/docs/getting-started.ko.md b/docs/getting-started.ko.md index 04db46f..1e58725 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.4`로 버전을 고정할 수 있습니다. 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.5`로 버전을 고정할 수 있습니다. AppImage는 FUSE 2가 필요합니다. Ubuntu 24.04 이상에서는 `sudo apt install libfuse2t64`를 실행하세요. 두 방법 모두 이후에는 앱 안에서 업데이트됩니다. 셸에 넘기기 전에 스크립트를 읽어 보셔도 좋습니다: [`scripts/install.sh`](../scripts/install.sh). 직접 파일을 받으려면: -OS와 CPU에 맞는 [v0.8.4 프리뷰](https://github.com/eggp-dev/conn/releases/tag/v0.8.4)를 받으세요. 실제 서명 상태와 실행 검증 결과는 릴리스 안내에서 확인할 수 있습니다. +OS와 CPU에 맞는 [v0.8.5 프리뷰](https://github.com/eggp-dev/conn/releases/tag/v0.8.5)를 받으세요. 실제 서명 상태와 실행 검증 결과는 릴리스 안내에서 확인할 수 있습니다. | 플랫폼 | 다운로드 | 설치 | |---|---|---| -| Ubuntu x64 | [`.deb`](https://github.com/eggp-dev/conn/releases/download/v0.8.4/conn-v0.8.4-x86_64-unknown-linux-gnu-desktop.deb) | 아래 명령으로 설치한 뒤 앱 목록에서 Conn을 엽니다. | -| Ubuntu x64 | [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.4/conn-v0.8.4-x86_64-unknown-linux-gnu-desktop.AppImage) | 실행 권한을 준 뒤 파일을 엽니다. | -| macOS Apple Silicon | [`.dmg`](https://github.com/eggp-dev/conn/releases/download/v0.8.4/conn-v0.8.4-aarch64-apple-darwin-desktop.dmg) | DMG를 열고 Conn을 Applications로 옮긴 뒤 그곳에서 실행합니다. | -| Windows x64 | [설치 `.exe`](https://github.com/eggp-dev/conn/releases/download/v0.8.4/conn-v0.8.4-x86_64-pc-windows-msvc-setup.exe) | 설치 파일을 실행하고 시작 메뉴에서 Conn을 엽니다. | +| Ubuntu x64 | [`.deb`](https://github.com/eggp-dev/conn/releases/download/v0.8.5/conn-v0.8.5-x86_64-unknown-linux-gnu-desktop.deb) | 아래 명령으로 설치한 뒤 앱 목록에서 Conn을 엽니다. | +| Ubuntu x64 | [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.5/conn-v0.8.5-x86_64-unknown-linux-gnu-desktop.AppImage) | 실행 권한을 준 뒤 파일을 엽니다. | +| macOS Apple Silicon | [`.dmg`](https://github.com/eggp-dev/conn/releases/download/v0.8.5/conn-v0.8.5-aarch64-apple-darwin-desktop.dmg) | DMG를 열고 Conn을 Applications로 옮긴 뒤 그곳에서 실행합니다. | +| Windows x64 | [설치 `.exe`](https://github.com/eggp-dev/conn/releases/download/v0.8.5/conn-v0.8.5-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.4-x86_64-unknown-linux-gnu-desktop.deb +sudo apt install ./conn-v0.8.5-x86_64-unknown-linux-gnu-desktop.deb ``` AppImage를 선택했다면 다음과 같이 실행합니다. ```sh -chmod +x ./conn-v0.8.4-x86_64-unknown-linux-gnu-desktop.AppImage -./conn-v0.8.4-x86_64-unknown-linux-gnu-desktop.AppImage +chmod +x ./conn-v0.8.5-x86_64-unknown-linux-gnu-desktop.AppImage +./conn-v0.8.5-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.4/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.5/SHA256SUMS)를 받으세요. Linux는 `sha256sum --ignore-missing -c SHA256SUMS`, macOS는 `shasum -a 256 <파일>` 결과와 해당 줄 비교, Windows는 `Get-FileHash <파일> -Algorithm SHA256`을 사용합니다. 체크섬은 파일 손상을 확인하며 코드 서명과는 별개입니다. ## 2. 터미널 설정 @@ -53,7 +53,7 @@ Linux 빌드 대상은 Ubuntu 24.04·26.04 x64이며 AppImage에도 시스템 2. **테마 → 언어**에서 English 또는 한국어를 고릅니다. 핸드오프 효과를 끌 수 있으며 아이콘은 OS의 모션 줄이기 설정도 따릅니다. 3. 설정을 닫고 **오른쪽 위 제어 상태 표시**를 누릅니다. 첫 요청에서는 **Autopilot**, **제어권 부여 전 확인 켜기**, **실행 유예 2초**로 설정하세요. -Autopilot은 정책 안에서 에이전트 실행을 허용합니다. **Co-pilot**에서는 제안된 명령마다 사람이 Enter를 누르며, **Observe**는 에이전트가 쓰지 않고 읽기만 하게 합니다. +Autopilot은 POSIX SSH에서도 일반 명령을 실행하고 정책이 지정한 위험 명령은 승인을 기다립니다. **세션 허용**은 해당 터미널 세션에서 같은 확인 규칙만 생략하며, 다른 규칙은 유지합니다. **Co-pilot**에서는 제안된 명령마다 사람이 Enter를 누르며, **Observe**는 에이전트가 쓰지 않고 읽기만 하게 합니다. ## 에이전트 연결 diff --git a/docs/getting-started.md b/docs/getting-started.md index bcbe87a..a93f08c 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.4` 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.5` 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.4 preview](https://github.com/eggp-dev/conn/releases/tag/v0.8.4) for your OS and CPU. See the release notes for the exact signing and runtime verification results. +Download the [v0.8.5 preview](https://github.com/eggp-dev/conn/releases/tag/v0.8.5) 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.4/conn-v0.8.4-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.4/conn-v0.8.4-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.4/conn-v0.8.4-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.4/conn-v0.8.4-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.5/conn-v0.8.5-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.5/conn-v0.8.5-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.5/conn-v0.8.5-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.5/conn-v0.8.5-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.4-x86_64-unknown-linux-gnu-desktop.deb +sudo apt install ./conn-v0.8.5-x86_64-unknown-linux-gnu-desktop.deb ``` Or, for AppImage: ```sh -chmod +x ./conn-v0.8.4-x86_64-unknown-linux-gnu-desktop.AppImage -./conn-v0.8.4-x86_64-unknown-linux-gnu-desktop.AppImage +chmod +x ./conn-v0.8.5-x86_64-unknown-linux-gnu-desktop.AppImage +./conn-v0.8.5-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.4/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.5/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 @@ -53,7 +53,7 @@ Open **the C icon at the top left → Settings**. 2. Under **Appearance → Language**, choose English or 한국어. Handoff effects can be disabled; the icon also respects the OS reduced-motion setting. 3. Close Settings and open the **top-right control indicator**. For your first request, choose **Autopilot**, enable **Ask before granting**, and set **Grace** to 2 seconds. -Autopilot allows agent execution within policy. **Co-pilot** lets you accept each proposed line with Enter; **Observe** allows reading without agent writes. +Autopilot runs ordinary commands and asks for those flagged by policy, including in POSIX SSH. **Allow this session** skips the matching confirm rule in that terminal session; other rules remain in force. **Co-pilot** lets you accept each proposed line with Enter; **Observe** allows reading without agent writes. ## Connect your agent diff --git a/docs/platform-support.ko.md b/docs/platform-support.ko.md index ee21be6..3610d40 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.4)로 제공합니다. 모든 데스크톱 패키지는 같은 Rust 엔진과 Svelte UI를 사용하며 브라우저 하네스는 개발용 어댑터입니다. +Conn은 MIT 라이선스 소스와 네이티브 바이너리를 [GitHub Releases](https://github.com/eggp-dev/conn/releases/tag/v0.8.5)로 제공합니다. 모든 데스크톱 패키지는 같은 Rust 엔진과 Svelte UI를 사용하며 브라우저 하네스는 개발용 어댑터입니다. ## 다음 프리뷰 릴리스 대상 diff --git a/docs/platform-support.md b/docs/platform-support.md index 8f4ac93..7cef440 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.4). 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.5). 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/security.md b/docs/security.md index ac58f83..17de7e7 100644 --- a/docs/security.md +++ b/docs/security.md @@ -63,11 +63,13 @@ Owner approval operations now live in the app, not public CLI/socket commands. ## A cooperative guard, not OS isolation Conn's policy combines pattern matching and structural analysis of submitted shell lines. -Remote, non-POSIX and shared external-origin sessions require command review rather than -resolving their targets against the host filesystem. In an integrated local shell, -an unconfirmed foreground program (including SSH or an editor) also requires review -until the trusted outer shell reports completion. Session allowances do not remove -that review requirement. Explicit deny rules remain in force. See [backend boundaries](backends.md#execution-and-policy-boundaries). +In POSIX SSH sessions, Autopilot applies command rules: ordinary commands run, confirm +rules ask for approval, and deny rules block. A session allowance lifts only its matching +confirm label. Remote cwd, home and filesystem targets are never resolved on the host. +This also applies to direct SSH launches and an `ssh` command identified by local +Bash/Zsh integration; the matching outer-shell exit restores local policy. Other remote +backends, non-POSIX shells and unclassified foreground or external programs still require +review for every command, without session allowances. Explicit deny rules remain in force. See [backend boundaries](backends.md#execution-and-policy-boundaries). Shell aliases/functions, variables, scripts, nested shells and interactive line editors can change what a submitted line does. Planned commands and intent are agent-supplied @@ -228,6 +230,9 @@ from saved timeline data. Back up work as files/version control. 기록합니다. 외부 세션에는 공유 전환 시 훅을 새로 설치하지 않습니다. - v0.7.0 프리뷰의 내장 OpenAI 명령 제안은 제거했습니다. Conn은 모델 요청을 보내거나 API 키를 저장하지 않습니다. 그때 저장한 키는 OS 보안 저장소에서 직접 삭제해야 합니다. +- POSIX SSH의 Autopilot은 명령 정책을 적용합니다. 일반 명령은 실행하고 위험 명령은 + 승인을 기다리며, 세션 허용은 해당 확인 규칙에만 적용합니다. 원격 파일은 로컬에서 + 검사하지 않습니다. 다른 원격 백엔드·비POSIX 셸·식별하지 못한 프로그램은 명령별 검토를 유지합니다. - 정책·공유 제한은 OS 격리가 아닙니다. 같은 계정의 다른 도구, 로그인된 원격 계정의 권한, 자식 기록·argv·환경·과거 스냅샷까지 제거하지 않습니다. - 네이티브 화면과 외부 런처는 각 플랫폼에서 별도 검증해야 합니다. diff --git a/docs/ssh-input-review-results.ko.md b/docs/ssh-input-review-results.ko.md new file mode 100644 index 0000000..cacb64f --- /dev/null +++ b/docs/ssh-input-review-results.ko.md @@ -0,0 +1,65 @@ +# SSH 입력과 승인 수정 결과 + +[English](ssh-input-review-results.md) · [테스트 재현 방법](browser-testing.md#ssh-input-cursor-and-approval-regression) + +2026-09-22 · v0.8.4 (`06143d0`) 기준 v0.8.5 패치. + +## 사용자 보고와 재현한 원인 + +사용자는 macOS에서 SSH 원격 셸을 사용하며, 긴 명령 이후 커서·입력 위치가 깨지고 **세션 허용**이 반응하지 않으며 위험 명령만 승인받는 자동 실행을 찾기 어렵다고 보고했다. 원하는 동작은 일반 명령 자동 실행, 위험 명령 승인이다. + +- **승인:** SSH가 모든 명령을 개별 검토로 강제해 Autopilot에서도 세션 허용을 막았다. UI는 이유 없이 비활성화한 버튼을 표시했다. +- **입력·커서:** 설정은 모달로 바뀌었지만 예전 사이드 패널용 600픽셀 너비 축소가 남아 있었다. 설정을 열고 닫는 애니메이션 동안 실제 PTY가 반복해서 변경됐다. 실제 SSH의 긴 입력에서 화면 깨짐과 사용자 화면·코어 그리드 불일치를 재현했다. +- **긴 승인 내용:** 약 5,000자 명령이 승인 버튼을 화면 아래로 밀어냈다. 공통 카드에서 내용만 스크롤하고 동작 버튼은 유지하도록 수정했다. + +## 변경 후 동작 + +POSIX SSH는 호스트의 cwd·홈·파일 경로를 검사하지 않고 기존 명령 정책을 적용한다. Autopilot에서 일반 명령은 실행하고 확인 규칙은 승인을 요청하며 금지 규칙은 계속 차단한다. 세션 허용은 해당 확인 규칙 이름에만 적용하고 UI에 즉시 알린다. Co-pilot, 연결 승인, 공유, 제어권 부여는 별도 책임을 유지한다. + +SSH 프로필, 직접 실행한 SSH, Bash/Zsh 통합이 식별한 단일 SSH 명령에 적용한다. 해당 SSH 종료가 확인되면 로컬 분석으로 돌아간다. SSH 실행 상태 추적이 끊기면 개별 검토로 전환한다. 다른 원격 백엔드·비POSIX 셸·식별하지 못한 프로그램은 명령별 검토를 유지하고 UI에서 이유를 설명한다. + +모달 설정을 열어도 뒤쪽 PTY 크기를 바꾸지 않는다. 실제 창과 협업 패널 크기 변경은 그대로 반영한다. 셸 교체, 원격 훅, 별도 실행 경로는 추가하지 않았다. + +## 검증과 한계 + +- 기본 Rust 테스트 258개 통과. 기존 선택 실행 SSH 테스트는 이 수치에서 제외했다. +- 별도 실제 OpenSSH 인증·공유 테스트 통과. 원격 일반 명령 실행과 권한 상승 명령의 승인 대기를 확인했고, 권한 상승 명령은 실행하지 않았다. 비공유 인증정보는 공유 데이터에 남지 않았다. +- 프런트엔드 48개, 데스크톱 프런트엔드 빌드, 코어/xterm 화면·커서 비교 85회, 사이트 빌드, 저장소·문서 검사 통과. +- 공통 UI·실제 Rust·루프백 SSH 하네스에서 240~7,000자 입력, 한글·이모지, 반복 설정 열기·닫기, 작은 창의 긴 명령 승인, 세션 허용 범위, 사람의 커서 이동·중간 삽입·제어권 회수, 원래 로컬 셸 복귀를 검사했다. +- 기존 협업 맥락 하네스는 탭·알림·공유·재접속·셸 준비 12단계를 검증한다. + +위 브라우저 검증 환경은 Linux Chromium과 UTF-8 원격 Bash다. 아래 macOS 네이티브 검증은 별도 실행 결과다. 사용자 증상의 모든 원인이 해결됐다고 단정하지 않으며, 배포 파일 검증은 별도로 기록한다. + +## macOS 네이티브 후속 검증 + +2026-09-22, `codex/terminal-input-review`의 `ed57aac936bec98e26f0a69ac06241cef36c5834`를 Apple Silicon Mac에서 확인했다. 작업 폴더를 새로 복제했고 기존 로컬 변경은 없었다. [해당 커밋의 CI](https://github.com/eggp-dev/conn/actions/runs/35691004269)는 Linux·macOS·Windows의 Rust/데스크톱, 문서·프런트엔드, 협업 브라우저와 필수 검사 모두 성공했다. PR #63은 Draft 상태였다. + +### 실행 환경과 증거 범위 + +- macOS 27.0 (`26A428`), arm64, Node.js 24.13.0, Rust 1.95.0. 루트 `npm ci` 완료. +- `frontends/tauri`에서 `npm run tauri dev`로 네이티브 실행을 확인했다. UI 조작 도구가 번들 없는 개발 프로세스를 선택하지 못해, 최종 조작은 같은 소스의 `npm run tauri build -- --debug --bundles app --config <임시 설정>`으로 만든 ad-hoc 서명 디버그 번들에서 진행했다. 임시로 감싼 개발 바이너리는 최종 검증 대상이 아니다. +- 테스트용 앱 이름·식별자, `CONN_CONFIG_DIR`, `CONN_SOCKET`을 분리하고 실행 바이너리 경로와 소켓 소유 PID를 확인했다. WebKit의 실제 `tauri://localhost` UI와 Tauri IPC를 사용했으며 브라우저 하네스로 대체하지 않았다. +- 별도 루프백 OpenSSH 서버, 임시 키·고정 호스트 키, 실제 `/bin/bash --noprofile --norc -i`, `en_US.UTF-8`, `xterm-256color`. SSH 명령은 Conn의 기존 로컬 PTY에서 입력했다. 실제 삭제 대상은 이번 실행에서 만든 임시 파일 두 개뿐이다. +- Mac에서 Rust 258개 통과·기존 선택 실행 SSH 테스트 1개 제외, 프런트엔드 48개 통과, 프런트엔드·네이티브 디버그 번들 빌드 통과, 코어/xterm 비교 85회 통과. 마지막 비교는 자동 파서 검사이며 네이티브 DOM 전체 비교 횟수가 아니다. + +### 직접 확인한 동작 + +| 항목 | 결과 | +| --- | --- | +| 일반 명령 자동 실행 | 오른쪽 위 제어 상태에서 Autopilot을 선택했다. 연결 허용과 제어권 승인은 각각 별도로 진행했고 원격 `printf`, 임시 파일 `touch`는 명령 승인 없이 `executed`를 반환했다. | +| 긴 영문 입력과 설정 | 1,521자·7,029자 입력에서 설정 열기 전후 PTY 크기, 화면, 커서, 출력 순번이 동일했다. 반복 열기·닫기 후에도 입력을 유지했고 7,000자 본문 출력과 프롬프트 복귀를 확인했다. 설정을 연 상태에서도 같은 에이전트가 실행할 수 있었다. | +| 한글·이모지와 실제 창 크기 | `한글/경로/🙂/` 65회 반복을 붙여넣었다. 설정 열기·닫기와 1120×720 → 757×433 → 원래 크기 변경을 확인했다. 137열에서 90열로 바뀐 화면의 커서 위치를 코어 스냅샷과 대조했다. | +| 사람의 편집·제어권 회수 | 영문·한글 긴 명령에서 좌우 이동, 중간 삽입, Backspace, Enter를 조작해 `EDITEND>`·`수정END>` 출력을 확인했다. 에이전트 제어 중 첫 방향키 입력은 `human_input`으로 제어권을 회수했고 이후 에이전트 쓰기는 `not_controller`로 거절됐다. | +| 긴 승인 카드 | 5,109자 삭제 명령은 실행되지 않고 `delete files` 승인 대기가 됐다. 757×433 창에서 내용은 카드 안에서 스크롤되고 승인·거부·세션 허용 버튼이 보였다. **세션 허용** 클릭 후 `granted`와 임시 파일 삭제를 확인했다. | +| 허용 범위와 철회 | 다음 임시 파일 삭제는 승인 없이 실행됐다. `recursive delete`와 `privilege escalation`은 계속 승인 대기했고 UI에서 거부했다. 설정의 허용 목록에는 `delete files`만 표시됐다. 이를 철회하자 같은 규칙도 다시 승인 대기가 됐다. 한국어 UI에서 개별 승인도 확인했다. | +| 금지 규칙 | 정책 테스트의 루트 삭제 명령은 `deny`였다. 실제 에이전트 Enter 경로는 위험 문자열을 인자로만 담은 무해한 `printf '%s\n' 'rm -rf / '`로 검사해 `delete root`·`denied`와 정책 차단 타임라인을 확인했다. 실제 루트 삭제 명령은 실행 경로에 제출하지 않았다. | +| 승인 중 사람 입력 | Ctrl-C가 대기 명령을 실행하지 않고 `denied (human_input)`으로 정리하며 제어권을 회수했다. 이후 사람의 명령으로 동일 원격 Bash PID를 확인했다. | +| 원래 셸 복귀 | SSH 종료 후 처음의 로컬 Bash PID와 동일한 PID를 확인했다. 세션 화면 식별자도 유지됐다. | + +네이티브 화면은 접근성 트리와 창 캡처로 확인하고, 선택한 화면·커서 위치는 에이전트 스냅샷과 대조했다. 모든 네이티브 행을 자동 비교한 검증은 아니다. 소켓 응답, 스냅샷, 감사 기록과 빌드 로그는 Mac의 임시 검증 폴더에 보관했으며 개인 경로·키·원시 로그·캡처는 저장소에 추가하지 않았다. 추가 제품 코드 변경은 하지 않았다. + +### 남은 확인과 관찰 + +- **실제 키보드 한글 IME 조합은 미검증이다.** 자동 키 입력에서는 입력란의 `한글`과 PTY의 초성이 달랐다. 임시 WebKit 이벤트 추적에는 `compositionstart/update/end`가 없고 `isComposing=false`인 `insertText`/`insertReplacementText`와 키 이벤트가 기록됐다. 이 전달 방식으로 실제 키보드 조합의 성공·실패를 판정할 수 없어 제품 우회 코드를 넣지 않았다. 한글·이모지 붙여넣기와 커서 편집 통과를 IME 통과로 확대하지 않는다. 진단용 추적은 제거했다. +- WebKit 검사기에 크기·패널 변경 뒤 `ResizeObserver loop completed with undelivered notifications` 메시지가 누적돼 있었다. 확인한 화면·커서·편집 단계에서 지속되는 불일치는 재현되지 않았으나, 이를 오류 없는 콘솔 실행으로 기록하지 않는다. +- 사용자의 실제 SSH 서버·셸 설정, 비UTF-8 줄 편집기, 다른 macOS 버전, 서명·공증된 배포 앱의 설치·업데이트는 별도 확인이 필요하다. 이 네이티브 검증 당시 버전은 0.8.4, 변경 내역은 Unreleased였다. 해당 검증 자체는 머지·태그·릴리스가 아니다. diff --git a/docs/ssh-input-review-results.md b/docs/ssh-input-review-results.md new file mode 100644 index 0000000..4b076a2 --- /dev/null +++ b/docs/ssh-input-review-results.md @@ -0,0 +1,65 @@ +# SSH input and approval fixes + +[한국어](ssh-input-review-results.ko.md) · [Reproduce the tests](browser-testing.md#ssh-input-cursor-and-approval-regression) + +2026-09-22 · v0.8.5 patch based on v0.8.4 (`06143d0`). + +## Report and reproduced causes + +The user reported macOS with an SSH remote shell: long command input displaced the cursor/input position, **Allow this session** appeared unresponsive, and automatic execution with risk-based approval seemed unavailable. The requested behavior is ordinary commands executing automatically, with approval for risky commands. + +- **Approval:** SSH forced every command into review, disabling session allowances even in Autopilot. The UI displayed a disabled button without explaining why. +- **Input/cursor:** the modal settings sheet still activated an obsolete 600-pixel side-panel offset. Opening/closing settings repeatedly resized the real PTY during the width animation. Long input reproduced corruption and disagreement between rendered rows and the core grid in real SSH. +- **Long approval:** a roughly 5,000-character command pushed action buttons below the visible dock. The common card now scrolls its content while retaining its actions. + +## Resulting behavior + +POSIX SSH uses the existing command policy without host cwd, home or filesystem target inspection. Ordinary commands execute in Autopilot, confirm rules request approval, and deny rules remain blocked. Session allowance applies to the matching confirm label and is published to the UI immediately. Co-pilot, connection admission, sharing and control grants retain their separate roles. + +SSH profiles, direct SSH launches and a single SSH command identified by Bash/Zsh integration use this policy. The matching outer-shell exit restores local analysis. Lost SSH lifecycle requires review; other remote backends, non-POSIX shells and unclassified programs retain individual review, with an explanation in the UI. + +Opening modal settings no longer resizes the underlying PTY. Actual window and collaboration-dock changes still resize it. No replacement shell, remote hook or separate execution channel is introduced. + +## Verification and limits + +- 258 default Rust tests passed; the existing optional SSH test is excluded from that count. +- The optional real OpenSSH authentication/sharing test passed separately, including ordinary remote execution and a privileged command held for review without execution. Private credentials stayed absent from shared data. +- 48 frontend tests, the desktop frontend build, 85 core/xterm grid-and-cursor comparisons, site build and repository/document checks passed. +- The production UI with the real Rust backend and loopback SSH exercised 240–7,000-character input, Korean/emoji input, repeated settings open/close, long-command approval at a narrow viewport, session allowance scope, human cursor movement/insertion, takeover and return to the original local shell. +- The existing collaboration-context harness covers the 12 tab, notice, sharing, reconnect and preparation stages. + +The browser tests above use Linux Chromium and remote Bash with UTF-8 locale. The native macOS run below is separate evidence. Neither establishes that every cause of the user's symptom is resolved. Release artifact validation is reported separately. + +## Native macOS follow-up + +On 2026-09-22, `ed57aac936bec98e26f0a69ac06241cef36c5834` on `codex/terminal-input-review` was checked on an Apple Silicon Mac. The checkout was newly cloned with no existing local changes. [CI for that commit](https://github.com/eggp-dev/conn/actions/runs/35691004269) passed the Linux/macOS/Windows Rust and desktop jobs, docs/frontend, collaboration browser flows and required checks. PR #63 remained a draft. + +### Environment and evidence + +- macOS 27.0 (`26A428`), arm64, Node.js 24.13.0, Rust 1.95.0; root `npm ci` completed. +- `npm run tauri dev` from `frontends/tauri` launched the native application. The UI automation tool could not select the unbundled development process, so final interaction used an ad-hoc signed debug bundle built from the same source with `npm run tauri build -- --debug --bundles app --config `. A temporary wrapper around the development executable was not the final candidate. +- A distinct test app name/identifier, `CONN_CONFIG_DIR` and `CONN_SOCKET` separated the run. The executable path and socket-owning PID were checked. Interaction used the real `tauri://localhost` WebKit UI and Tauri IPC, not the browser harness. +- A separate loopback OpenSSH server used temporary keys and a pinned host key, real `/bin/bash --noprofile --norc -i`, `en_US.UTF-8` and `xterm-256color`. SSH was entered through Conn's existing local PTY. Actual deletions affected only two disposable files created for this run. +- On the Mac: 258 Rust tests passed, with the existing optional SSH test excluded; 48 frontend tests, frontend and native debug-bundle builds, and 85 core/xterm comparisons passed. The last count describes automated parser checks, not native DOM comparisons. + +### Native interaction results + +| Check | Result | +| --- | --- | +| Ordinary automatic execution | Autopilot was selected in the upper-right control center. Connection admission and control approval were handled separately. Remote `printf` and fixture `touch` returned `executed` without command approval. | +| Long ASCII input and settings | With 1,521- and 7,029-character input, opening settings preserved PTY size, screen, cursor and output sequence. Repeated open/close preserved the input; the 7,000-character payload printed and returned to the prompt. The same agent could execute while settings was open. | +| Korean/emoji and actual window resizing | Pasted 65 repetitions of `한글/경로/🙂/`. Opened/closed settings and resized 1120×720 → 757×433 → original size. Compared the visible cursor with core snapshots as the terminal changed from 137 to 90 columns. | +| Human editing and takeover | Used left/right movement, insertion, Backspace and Enter in long ASCII/Unicode commands; verified `EDITEND>` and `수정END>` output. The first human arrow key revoked the agent's lease with `human_input`; its next write returned `not_controller`. | +| Long approval card | A 5,109-character deletion command stayed pending under `delete files`. At 757×433, content scrolled inside the card and approve/deny/session-allow buttons remained visible. Clicking **Allow this session** returned `granted` and removed the disposable file. | +| Allowance scope and revocation | A second disposable file deletion executed without approval. `recursive delete` and `privilege escalation` still requested approval and were denied through the UI. Settings showed only `delete files` as allowed. Revoking it restored approval for the same rule; individual approval also worked in Korean. | +| Deny rules | The nonexecuting policy tester denied root deletion. The actual agent Enter path was tested with harmless `printf '%s\n' 'rm -rf / '`, which contains the dangerous text only as an argument: it returned `delete root`/`denied` and appeared as policy-blocked in the timeline. No actual root-deletion command was submitted for execution. | +| Human input during approval | Ctrl-C resolved the pending command as `denied (human_input)` without execution and reclaimed control. A subsequent human command confirmed the same remote Bash PID. | +| Original shell return | Exiting SSH returned to the original local Bash PID. The session screen identity also remained unchanged. | + +Native accessibility trees and window captures were inspected; selected screen/cursor positions were compared with agent snapshots. This was not an automated comparison of every native rendered row. Socket responses, snapshots, audit records and build logs were kept in the Mac's temporary validation directory. Personal paths, keys, raw logs and captures were not added to the repository. No additional product code change was made. + +### Remaining checks and observations + +- **Physical-keyboard Korean IME composition remains unverified.** Automated keys produced composed `한글` in the textarea but initial consonants in the PTY. A temporary WebKit event trace contained no `compositionstart/update/end`; it recorded `insertText`/`insertReplacementText` with `isComposing=false` and key events. This delivery cannot establish success or failure for physical-keyboard composition, so no product workaround was added. Successful Unicode paste/editing is not an IME acceptance claim. The temporary trace was removed. +- WebKit's inspector had accumulated `ResizeObserver loop completed with undelivered notifications` messages after window/dock changes. Checked screen/cursor/editing steps did not establish persistent disagreement; this is not an error-free-console claim. +- The user's actual SSH server/shell setup, non-UTF-8 line editors, other macOS versions, and signed/notarized installed-app upgrades still need separate acceptance. At the time of this native run, the checkout was version 0.8.4 with changes under Unreleased; this run itself was not a merge, tag or release. diff --git a/frontends/tauri/package.json b/frontends/tauri/package.json index 118e408..46e2fbd 100644 --- a/frontends/tauri/package.json +++ b/frontends/tauri/package.json @@ -1,7 +1,7 @@ { "name": "conn-desktop", "private": true, - "version": "0.8.4", + "version": "0.8.5", "type": "module", "scripts": { "dev": "vite", @@ -12,6 +12,7 @@ "test:collaboration": "node scripts/collaboration-test.mjs", "test:collaboration:harness": "node scripts/collaboration-harness-test.mjs", "test:context:harness": "node scripts/context-harness-test.mjs", + "test:input:harness": "node scripts/input-review-harness-test.mjs", "test:screen": "node scripts/screen-parity-test.mjs" }, "dependencies": { diff --git a/frontends/tauri/scripts/input-review-harness-test.mjs b/frontends/tauri/scripts/input-review-harness-test.mjs new file mode 100644 index 0000000..0f39e59 --- /dev/null +++ b/frontends/tauri/scripts/input-review-harness-test.mjs @@ -0,0 +1,144 @@ +// Production UI, Rust owner boundary, real PTYs and persistent agent sockets. +// Synthetic fixtures only. Evidence lives outside the checkout by default. +import {chromium} from 'playwright'; +import {spawn} from 'node:child_process'; +import {mkdtempSync,writeFileSync,existsSync,mkdirSync,openSync,readFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join,resolve} from 'node:path'; +import net from 'node:net'; +import {strict as assert} from 'node:assert'; +const dir=mkdtempSync(join(tmpdir(),'conn-input-review-')); +const output=process.env.CONN_INPUT_REVIEW_OUTPUT ?? join(dir,'evidence');mkdirSync(output,{recursive:true}); +const port=process.env.CONN_HARNESS_UI_PORT??'1471',backend=process.env.CONN_HARNESS_PORT??'1473'; +const env={...process.env,CONN_TEST_PORT:backend,CONN_TEST_ORIGIN:`http://127.0.0.1:${port}`,CONN_TEST_STATE:dir}; +writeFileSync(join(dir,'app.json'),JSON.stringify({mode:'autopilot',gate:true,pacing:{enterGraceMs:0,minWriteIntervalMs:0,leaseTtlSecs:120,approvalTtlSecs:120}})); +writeFileSync(join(dir,'profiles.json'),JSON.stringify({version:1,revision:0,defaultProfile:'fixture',profiles:[{id:'fixture',name:'Fixture',program:process.env.CONN_REPRO_SHELL??'/bin/bash',args:process.env.CONN_REPRO_SHELL?['-i']:['--noprofile','--norc'],env:{PS1:'fixture$ ',TERM:'xterm-256color'},cwd:dir}]})); +const log=openSync(join(dir,'harness.log'),'w'); +const server=spawn(resolve('../../target/debug/conn-browser-harness'),[dir],{env,stdio:['ignore',log,log]}); +const delay=ms=>new Promise(r=>setTimeout(r,ms)); +let vite,browser,context,page;const clients=[],errors=[],stages=[]; +async function connect(name){ + const socket=net.createConnection(join(dir,'conn.sock'));let seq=0,buffer='';const pending=new Map(); + await new Promise((r,j)=>{socket.once('connect',r);socket.once('error',j)}); + socket.on('data',chunk=>{buffer+=chunk;let at;while((at=buffer.indexOf('\n'))>=0){const m=JSON.parse(buffer.slice(0,at));buffer=buffer.slice(at+1);const p=pending.get(m.id);if(p){clearTimeout(p.timer);pending.delete(m.id);m.error?p.reject(Object.assign(Error(m.error.message),m.error)):p.resolve(m.result)}}}); + const client={call:(method,params={})=>new Promise((resolve,reject)=>{const id=++seq,timer=setTimeout(()=>{pending.delete(id);reject(Error(`RPC timeout: ${method}`))},18000);pending.set(id,{resolve,reject,timer});socket.write(JSON.stringify({id,method,params})+'\n')}),close(){for(const p of pending.values())clearTimeout(p.timer);socket.destroy();}}; + clients.push(client);client.hello=await client.call('hello',{kind:'agent',agentId:name});return client; +} +const state=()=>page.evaluate(async()=>{const {st}=await import('/src/lib/store.svelte.ts');return {active:st.active,order:st.order,tabs:st.tabs,activity:st.activity,notice:st.announcement};}); +async function capture(name,extra={}){await delay(500);stages.push({name,...await state(),...extra});writeFileSync(join(output,'states.json'),JSON.stringify(stages,null,2));await page.screenshot({path:join(output,`${name}.png`)});console.log(`PASS ${name}`);} +async function until(test,what){for(let i=0;i<100;i++){if(await test())return;await delay(100)}throw Error(`Timed out: ${what}`);} +async function allow(agent){await page.locator('.decision-card .primary').click();await until(async()=>(await state()).activity.connections.some(a=>a.connId===agent.hello.conn),'admission resolved');} +// fill() cannot deliver terminal input; keyboard insertion emits the real onData path. +async function typeHuman(text){const input=page.locator('.xterm-helper-textarea:visible');await input.click();await page.keyboard.insertText(text);await input.press('Enter');await delay(300);} +async function grid(agent,label){ + await delay(700); + const snapshot=await agent.call('snapshot'); + const visible=await page.locator('.xterm-rows:visible > div').allTextContents(); + const rows=visible.map(row=>row.replace(/\u00a0/g,' ').trimEnd()); + const cursor=await page.evaluate(cols=>{ + const cell=document.querySelector('.host:not([hidden]) .xterm-cursor'); + const screen=document.querySelector('.host:not([hidden]) .xterm-screen'); + const row=cell?.closest('.xterm-rows > div'); + if(!cell||!screen||!row)return null; + return {row:Array.from(row.parentElement.children).indexOf(row),col:Math.round((cell.getBoundingClientRect().left-screen.getBoundingClientRect().left)/(screen.clientWidth/cols))}; + },snapshot.size.cols); + const result={label,size:snapshot.size,core:snapshot.screen,visible:rows,cursor:snapshot.cursor,visibleCursor:cursor,equal:JSON.stringify(snapshot.screen)===JSON.stringify(rows)}; + assert.deepEqual(cursor,snapshot.cursor,`${label}: user and agent cursor positions agree`); + writeFileSync(join(output,`grid-${label}.json`),JSON.stringify(result,null,2));return result; +} +try{ + for(let i=0;!existsSync(join(dir,'connection.json'));i++){if(i>100||server.exitCode!==null)throw Error('Rust startup failed');await delay(100)} + vite=spawn(process.execPath,['node_modules/vite/bin/vite.js','--mode','browser-test','--host','127.0.0.1','--port',port,'--strictPort'],{env,stdio:['ignore',log,log]}); + await until(async()=>{try{return(await fetch(`http://127.0.0.1:${port}/`)).ok}catch{return false}},'Vite'); + browser=await chromium.launch();context=await browser.newContext({viewport:{width:860,height:640}}); + await context.addInitScript(()=>localStorage.setItem('ss:lang','ko'));page=await context.newPage();page.setDefaultTimeout(12000); + page.on('pageerror',e=>errors.push(e.message));await page.goto(`http://127.0.0.1:${port}/`);await page.locator('.xterm-helper-textarea').waitFor();await delay(700); + if(process.env.CONN_AUTH_FIXTURE_RUNTIME){ + const runtime=process.env.CONN_AUTH_FIXTURE_RUNTIME; + const password=readFileSync(join(runtime,'password'),'utf8').trim();assert.ok(password.startsWith('SYNTHETIC_')); + await typeHuman(`ssh -F /dev/null -tt -p 22222 -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${runtime}/known_hosts -o PreferredAuthentications=password -o PubkeyAuthentication=no -o IdentityAgent=none fixture@127.0.0.1`); + await until(async()=>(await page.locator('.xterm-rows').innerText()).includes('password:'),'SSH password prompt'); + await typeHuman(password); + await typeHuman("exec env LC_ALL=C.UTF-8 PS1='remote$ ' bash --noprofile --norc -i"); + await typeHuman('printf "REMOTE_READY_%s\\n" "$((6*7))"'); + await until(async()=>(await page.locator('.xterm-rows').innerText()).includes('REMOTE_READY_42'),'remote marker'); + } + const agent=await connect('input-repro');await allow(agent); + const control=agent.call('request_control',{reason:'Reproduce long command rendering.'});control.catch(()=>{}); + await page.locator('.decision-card .primary').click();await control;await delay(500); + for(const length of [240,700,1500,3500,7000]){ + const text='abcdefghijklmnopqrst'.repeat(Math.ceil(length/20)).slice(0,length); + await agent.call('type',{text:`printf '%s\\n' '${text}'`}); + + const execution=await agent.call('send_key',{key:'ENTER',intent:'Print synthetic fixture output.'}); + assert.equal(execution.status,'executed','ordinary SSH command executes in Autopilot'); + await delay(500);assert.ok((await grid(agent,`printed-${length}`)).equal);await capture(`output-${length}`); + } + // The modal settings sheet must leave the underlying PTY dimensions alone. + for(const text of ['long-input/'.repeat(100), '한글/경로/🙂/'.repeat(65)]){ + await agent.call('type',{text:`printf '%s\\n' '${text}'`}); + const originalSize=(await agent.call('snapshot')).size; + await capture('before-settings-'+(text.startsWith('한')?'unicode':'ascii')); + for(let i=0;i<3;i++){ + await page.keyboard.press('Control+Shift+,');await delay(350); + assert.equal(await page.evaluate(async()=>(await import('/src/lib/store.svelte.ts')).st.settingsOpen),true); + assert.deepEqual((await agent.call('snapshot')).size,originalSize,'settings must not resize the PTY'); + await capture(`settings-${i}-${text.startsWith('한')?'unicode':'ascii'}`); + await page.keyboard.press('Escape');await delay(350); + } + assert.ok((await grid(agent,'edited-long-line')).equal); + await capture('after-settings-'+(text.startsWith('한')?'unicode':'ascii')); + const result=await agent.call('send_key',{key:'ENTER',intent:'Print the line after a panel resize.'}); + assert.equal(result.status,'executed');await delay(500); + } + const missing=join(dir,'missing-'+('deep/'.repeat(1000))+'file'); + await agent.call('type',{text:`rm -- '${missing}'`});const approval=await agent.call('send_key',{key:'ENTER',intent:'Remove an absent synthetic fixture path.'});assert.equal(approval.status,'pending'); + await page.locator('.decision-card .btn.ok').waitFor();await capture('long-approval');await page.setViewportSize({width:700,height:540});await delay(600);await capture('approval-narrow'); + const box=await page.getByRole('button',{name:'A 세션 허용',exact:true}).boundingBox(); + assert.ok(box && box.y>=0 && box.y+box.height<=540,'approval actions remain visible for a long command'); + await page.setViewportSize({width:860,height:640});await delay(600); + assert.equal((await state()).tabs[(await state()).active].reviewRequired,false); + const allowSession=page.getByRole('button',{name:'A 세션 허용',exact:true});assert.ok(await allowSession.isEnabled()); + await allowSession.click(); + await until(async()=>(await agent.call('check_approval',{approvalId:approval.approvalId})).state==='granted','approval granted'); + await delay(700);assert.ok((await grid(agent,'approved')).equal);await capture('after-allow'); + await until(async()=>(await state()).tabs[(await state()).active].allows.includes('delete files'),'session allowance shown'); + await agent.call('type',{text:`rm -- '${join(dir,'never-created')}'`}); + assert.equal((await agent.call('send_key',{key:'ENTER',intent:'Verify the same session rule on an absent fixture path.'})).status,'executed'); + await delay(500); + await agent.call('type',{text:'sudo id'}); + assert.equal((await agent.call('send_key',{key:'ENTER',intent:'Verify another risk still asks, without running it.'})).status,'pending'); + await page.locator('.decision-card .btn.danger').click();await delay(500); + const blocked=await page.evaluate(async()=>{ + const {cmd}=await import('/src/lib/bridge');return cmd('policy_test',{cmd:'rm -rf /'}); + }); + assert.equal(blocked.policy,'deny','session allowance never lifts deny rules'); + await delay(500); + await agent.call('type',{text:"printf '%s\\n' FINAL_MARKER"});const last=await agent.call('send_key',{key:'ENTER',intent:'Print final marker.'});assert.equal(last.status,'executed'); + await delay(500);assert.ok((await grid(agent,'final')).equal);await capture('final'); + // Human takeover, cursor editing and execution use the real terminal input path. + const long='cursor-edit/'.repeat(85); + await agent.call('type',{text:`printf '<%s>\\n' '${long}END'`}); + await delay(500); + const before=await grid(agent,'cursor-before-edit');assert.ok(before.equal); + await page.locator('.xterm-helper-textarea:visible').focus(); + for(let i=0;i<4;i++)await page.keyboard.press('ArrowLeft'); + await delay(300); + const moved=await grid(agent,'cursor-moved');assert.ok(moved.equal); + // Human takeover opens the handback dock and can reduce the row count. + const row=Math.min(before.cursor.row,moved.size.rows-1); + const start=row*before.size.cols+before.cursor.col-4; + assert.deepEqual(moved.cursor,{row:Math.floor(start/before.size.cols),col:start%before.size.cols}); + await page.keyboard.insertText('EDIT');await page.keyboard.press('End');await page.keyboard.press('Enter'); + await until(async()=>(await agent.call('snapshot')).screen.join('').includes('EDITEND>'),'edited command output'); + assert.equal((await state()).tabs[(await state()).active].controller.type,'human'); + assert.ok((await grid(agent,'human-edited')).equal);await capture('human-edited'); + if(process.env.CONN_AUTH_FIXTURE_RUNTIME){ + await typeHuman('exit'); + await until(async()=>page.evaluate(async()=>{const {cmd}=await import('/src/lib/bridge');return (await cmd('status')).completionPromptReady;}),'outer shell lifecycle restored'); + await typeHuman("printf 'LOCAL_BACK_%s\\n' 42"); + await until(async()=>(await agent.call('snapshot')).screen.some(row=>row==='LOCAL_BACK_42'),'outer shell restored'); + } + assert.deepEqual(errors,[]); + console.log('Evidence:',output); +}finally{for(const client of clients)client.close();await context?.close();await browser?.close();vite?.kill();server.kill();} diff --git a/frontends/tauri/src-tauri/tauri.conf.json b/frontends/tauri/src-tauri/tauri.conf.json index d8daec7..90788b3 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.4", + "version": "0.8.5", "identifier": "dev.eggp.conn", "build": { "beforeDevCommand": "npm run dev", diff --git a/frontends/tauri/src/App.svelte b/frontends/tauri/src/App.svelte index d8c20a4..b63845f 100644 --- a/frontends/tauri/src/App.svelte +++ b/frontends/tauri/src/App.svelte @@ -215,7 +215,6 @@ else if (e.altKey && key === "ArrowRight") { e.preventDefault(); const n = nextNeedingAttention(); if (n) select(n); } } - $effect(() => { void st.settingsOpen; const h = setTimeout(() => terms[st.active]?.refit(), 250); return () => clearTimeout(h); }); $effect(() => { if (!st.followSystem) return; @@ -394,7 +393,7 @@ -
+
{#each st.order as id (id)} {#if tab(id)?.statusReady}{/if} {/each} diff --git a/frontends/tauri/src/components/Center.svelte b/frontends/tauri/src/components/Center.svelte index 89e8ccd..128a91c 100644 --- a/frontends/tauri/src/components/Center.svelte +++ b/frontends/tauri/src/components/Center.svelte @@ -42,6 +42,8 @@ {/each} +

{t(`mode.${tb.mode}.desc`)}

+ {#if tb.reviewRequired}

{t('hold.review_required')}

{/if} {#if tb.effectiveMode !== tb.mode}

→ {t(`mode.${tb.effectiveMode}`)}

{/if}