From ed57aac936bec98e26f0a69ac06241cef36c5834 Mon Sep 17 00:00:00 2001 From: eggplantiny <57978095+eggplantiny@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:30:19 +0900 Subject: [PATCH 1/3] fix: restore SSH policy approvals and stabilize terminal input --- CHANGELOG.md | 8 + crates/core/src/engine.rs | 1 + crates/core/src/session.rs | 99 +++++++++++- crates/core/tests/control_regressions.rs | 31 +++- crates/core/tests/shell_integration.rs | 4 +- crates/frontend/src/automation/tests.rs | 13 ++ docs/backends.md | 22 ++- docs/browser-testing.md | 20 +++ docs/faq.ko.md | 2 +- docs/faq.md | 2 +- docs/getting-started.ko.md | 2 +- docs/getting-started.md | 2 +- docs/security.md | 15 +- docs/ssh-input-review-results.ko.md | 31 ++++ docs/ssh-input-review-results.md | 31 ++++ frontends/tauri/package.json | 1 + .../scripts/input-review-harness-test.mjs | 144 ++++++++++++++++++ frontends/tauri/src/App.svelte | 3 +- frontends/tauri/src/components/Center.svelte | 2 + .../tauri/src/components/DecisionCard.svelte | 12 +- frontends/tauri/src/components/Hold.svelte | 7 +- frontends/tauri/src/components/Term.svelte | 4 +- frontends/tauri/src/lib/i18n.svelte.ts | 8 +- site/README.md | 4 +- site/src/i18n/landing.ts | 6 +- site/src/story.ts | 4 +- 26 files changed, 439 insertions(+), 39 deletions(-) create mode 100644 docs/ssh-input-review-results.ko.md create mode 100644 docs/ssh-input-review-results.md create mode 100644 frontends/tauri/scripts/input-review-harness-test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index d0dbe48..6d099fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +- 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. + +한국어: POSIX SSH의 Autopilot에서 일반 명령은 자동 실행하고 위험 명령은 승인을 받도록 수정했습니다. 세션 허용은 해당 확인 규칙에 적용하고 즉시 표시하며, 명령별 검토가 필요한 경우에는 이유를 안내합니다. 모달 설정 뒤에서 남아 있던 터미널 너비 변경을 제거해 재현된 긴 입력·커서 깨짐을 수정했습니다. + +Validation and limits: [English](docs/ssh-input-review-results.md) · [한국어](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/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..7f30137 100644 --- a/docs/getting-started.ko.md +++ b/docs/getting-started.ko.md @@ -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..65b9ce8 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -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/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..6c16b7d --- /dev/null +++ b/docs/ssh-input-review-results.ko.md @@ -0,0 +1,31 @@ +# SSH 입력과 승인 수정 결과 + +[English](ssh-input-review-results.md) · [테스트 재현 방법](browser-testing.md#ssh-input-cursor-and-approval-regression) + +2026-09-22 · v0.8.4 (`06143d0`) 기준 미배포 변경. + +## 사용자 보고와 재현한 원인 + +사용자는 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 네이티브 WebKit, 사용자의 정확한 원격 셸 설정, 비UTF-8 줄 편집기, 설치된 앱 업데이트는 미검증이다. 아직 배포하지 않았다. diff --git a/docs/ssh-input-review-results.md b/docs/ssh-input-review-results.md new file mode 100644 index 0000000..f4235d6 --- /dev/null +++ b/docs/ssh-input-review-results.md @@ -0,0 +1,31 @@ +# 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 · Unreleased changes 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 use Linux Chromium and remote Bash with UTF-8 locale. They reproduce and fix the settings-resize case; they do **not** establish that every cause of the user's macOS symptom is resolved. Native macOS WebKit, the user's exact remote shell configuration, non-UTF-8 line editors and installed-app upgrades remain unverified. These changes have not been released. diff --git a/frontends/tauri/package.json b/frontends/tauri/package.json index 118e408..e34234d 100644 --- a/frontends/tauri/package.json +++ b/frontends/tauri/package.json @@ -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/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}