diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index d4aa249..fd465a1 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.2" + "version": "0.8.3" } ] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17e9526..62377bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,16 +150,39 @@ jobs: - name: Check macOS scripting metadata and compile external automation examples if: runner.os == 'macOS' run: python3 scripts/check_macos_scripting.py --app frontends/tauri/src-tauri/target/debug/bundle/macos/Conn.app + collaboration: + name: Collaboration browser flows + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.ref || github.sha }} + persist-credentials: false + - uses: ./.github/actions/setup + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + if: inputs.ref == '' + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + - name: Install the lockfile-pinned test browser + run: npx playwright install chromium --with-deps + working-directory: frontends/tauri + - run: cargo build --locked -p conn-browser-harness + - run: npm run test:collaboration + working-directory: frontends/tauri + - run: npm run test:collaboration:harness + working-directory: frontends/tauri required: name: Required checks if: always() - needs: [scope, quality, core, desktop] + needs: [scope, quality, core, desktop, collaboration] runs-on: ubuntu-24.04 steps: - name: Every job in scope passed env: SCOPE: ${{ needs.scope.result }} QUALITY: ${{ needs.quality.result }} + COLLABORATION: ${{ needs.collaboration.result }} CORE: ${{ needs.core.result }} DESKTOP: ${{ needs.desktop.result }} RUST_RUNNERS: ${{ needs.scope.outputs.rust }} @@ -167,5 +190,5 @@ jobs: run: | # A job may be skipped only because the scope left it nothing to do. expected() { if [ "$1" = '[]' ]; then test "$2" = skipped; else test "$2" = success; fi; } - test "$SCOPE" = success && test "$QUALITY" = success + test "$SCOPE" = success && test "$QUALITY" = success && test "$COLLABORATION" = success expected "$RUST_RUNNERS" "$CORE" && expected "$DESKTOP_RUNNERS" "$DESKTOP" diff --git a/CHANGELOG.md b/CHANGELOG.md index 256055b..69f33e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## Unreleased +## 0.8.3 — Preview · 2026-09-21 + +Validation: 246 Rust tests, 46 frontend tests, 78 release tests, production-App browser fixtures, the real Rust Harness collaboration flow and a real OpenSSH authentication/sharing regression passed locally. Interactive testing of this patch on macOS was not performed because the test Mac is offline; Linux/Windows native GUI acceptance and installed-app upgrade coverage also remain open. Release CI and signing checks are reported separately. + +한국어: 로컬 Rust 246개·프런트엔드 46개·릴리스 78개 테스트, 실제 App 브라우저 검증·Rust Harness 협업 흐름·실제 OpenSSH 인증 및 공유 회귀 검증을 통과했습니다. 테스트 Mac이 꺼져 있어 이번 패치의 macOS 대화형 검증은 하지 않았습니다. Linux·Windows 네이티브 GUI 및 설치된 앱의 업그레이드 검증도 남아 있습니다. + +- Connection approval stays actionable in private, empty, preparing and Settings views. Failed decisions remain retryable, and late snapshots cannot resurrect resolved requests. +- Sharing validates input, live participants, history resources and the selected revision before revoking external input. Failed transitions preserve the running shell and writer; stale selections require reloading. +- Centralize connection admission, cross-session control transitions, participation cleanup and typed UI state. Owner grants and hand-backs enforce one lease per connection; explicit take/release and process exit cancel pending work. Cards, keyboard and palette decisions share their request state. +- Add required browser collaboration regressions using the production App and real Rust Harness. [Implementation and validation](docs/collaboration-refactor-results.md); native platform acceptance remains open. + +한국어: 비공개 화면에서도 연결 승인을 처리하고, 공유 실패 시 기존 셸과 외부 입력 권한을 유지합니다. 연결·참여·제어권·UI 상태의 책임을 분리했으며, 오래된 공유 선택은 다시 확인하게 합니다. [구현·검증 결과](docs/collaboration-refactor-results.ko.md)에 플랫폼별 남은 검증을 구분했습니다. + - Snapshots no longer carry the constant `imageUnavailable: true` field, a remnant of the optional PNG snapshots removed in 0.8.0. Snapshots remain text only. - Removed the native OpenAI command suggestions preview, its provider settings and the OS credential-store key storage. Themes and the extension registry stay. A key saved earlier remains in the OS credential store under `dev.eggp.conn.model-provider` until you delete it there. diff --git a/Cargo.lock b/Cargo.lock index 7ceffe9..ef5465a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -696,7 +696,7 @@ dependencies = [ [[package]] name = "conn" -version = "0.8.2" +version = "0.8.3" dependencies = [ "clap", "conn-core", @@ -706,7 +706,7 @@ dependencies = [ [[package]] name = "conn-browser-harness" -version = "0.8.2" +version = "0.8.3" dependencies = [ "axum", "conn-core", @@ -719,7 +719,7 @@ dependencies = [ [[package]] name = "conn-core" -version = "0.8.2" +version = "0.8.3" dependencies = [ "chrono", "dirs 5.0.1", @@ -742,7 +742,7 @@ dependencies = [ [[package]] name = "conn-desktop" -version = "0.8.2" +version = "0.8.3" dependencies = [ "base64 0.22.1", "cc", @@ -764,7 +764,7 @@ dependencies = [ [[package]] name = "conn-frontend" -version = "0.8.2" +version = "0.8.3" dependencies = [ "base64 0.22.1", "conn-core", diff --git a/Cargo.toml b/Cargo.toml index c27afc1..9d250ce 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.2" +version = "0.8.3" edition = "2021" license = "MIT" repository = "https://github.com/eggp-dev/conn" [workspace.dependencies] -conn-core = { path = "crates/core", version = "0.8.2" } -conn-frontend = { path = "crates/frontend", version = "0.8.2" } +conn-core = { path = "crates/core", version = "0.8.3" } +conn-frontend = { path = "crates/frontend", version = "0.8.3" } 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 5dd9b3f..401126b 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.2 프리뷰 | +| 운영체제 | v0.8.3 프리뷰 | |---|---| -| macOS · Apple Silicon | [Mac용 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.8.2/conn-v0.8.2-aarch64-apple-darwin-desktop.dmg) | -| Windows · x64 | [설치 파일 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.8.2/conn-v0.8.2-x86_64-pc-windows-msvc-setup.exe) | -| Ubuntu · x64 | [.deb 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.8.2/conn-v0.8.2-x86_64-unknown-linux-gnu-desktop.deb) · [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.2/conn-v0.8.2-x86_64-unknown-linux-gnu-desktop.AppImage) | +| macOS · Apple Silicon | [Mac용 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.8.3/conn-v0.8.3-aarch64-apple-darwin-desktop.dmg) | +| Windows · x64 | [설치 파일 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.8.3/conn-v0.8.3-x86_64-pc-windows-msvc-setup.exe) | +| Ubuntu · x64 | [.deb 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.8.3/conn-v0.8.3-x86_64-unknown-linux-gnu-desktop.deb) · [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.3/conn-v0.8.3-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.2) +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.3) ## 처음 5분 diff --git a/README.md b/README.md index a822490..7567938 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.2 preview | +| Platform | v0.8.3 preview | |---|---| -| macOS · Apple Silicon | [Download for Mac](https://github.com/eggp-dev/conn/releases/download/v0.8.2/conn-v0.8.2-aarch64-apple-darwin-desktop.dmg) | -| Windows · x64 | [Download installer](https://github.com/eggp-dev/conn/releases/download/v0.8.2/conn-v0.8.2-x86_64-pc-windows-msvc-setup.exe) | -| Ubuntu · x64 | [Download .deb](https://github.com/eggp-dev/conn/releases/download/v0.8.2/conn-v0.8.2-x86_64-unknown-linux-gnu-desktop.deb) · [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.2/conn-v0.8.2-x86_64-unknown-linux-gnu-desktop.AppImage) | +| macOS · Apple Silicon | [Download for Mac](https://github.com/eggp-dev/conn/releases/download/v0.8.3/conn-v0.8.3-aarch64-apple-darwin-desktop.dmg) | +| Windows · x64 | [Download installer](https://github.com/eggp-dev/conn/releases/download/v0.8.3/conn-v0.8.3-x86_64-pc-windows-msvc-setup.exe) | +| Ubuntu · x64 | [Download .deb](https://github.com/eggp-dev/conn/releases/download/v0.8.3/conn-v0.8.3-x86_64-unknown-linux-gnu-desktop.deb) · [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.3/conn-v0.8.3-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.2) +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.3) ## First five minutes diff --git a/crates/core/src/agent_commands.rs b/crates/core/src/agent_commands.rs new file mode 100644 index 0000000..1550042 --- /dev/null +++ b/crates/core/src/agent_commands.rs @@ -0,0 +1,189 @@ +//! Agent session operations. Owner-only operations never enter this service. +use crate::{ + affordance::Actor, + authority::ConnId, + protocol::RpcError, + session::{ConnKind, Session, SessionError, SharedSession}, +}; +use serde_json::{json, Value}; + +/// Agents get one generic refusal so they cannot probe for private sessions. The +/// owner can ask why: `CONN_TRACE_REFUSALS=1` prints the cause to stderr. Reasons +/// and identifiers only, never terminal content. +pub(crate) fn trace_refusal(reason: &str, conn: ConnId, session: &str) { + static ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); + if *ENABLED + .get_or_init(|| std::env::var_os("CONN_TRACE_REFUSALS").is_some_and(|v| !v.is_empty())) + { + eprintln!( + "conn refusal {} conn={conn} session={} reason={reason}", + chrono::Local::now().format("%H:%M:%S%.3f"), + trace_label(session) + ); + } +} + +/// The named session or tab comes from the agent. Keep it to one bounded, escaped +/// token so a request cannot forge trace lines or drive the owner's terminal. +pub(crate) fn trace_label(requested: &str) -> String { + let mut label: String = requested + .chars() + .take(96) + .flat_map(char::escape_default) + .collect(); + if requested.chars().nth(96).is_some() { + label.push('…'); + } + label +} + +pub(crate) fn session_unavailable() -> RpcError { + RpcError { + code: "not_found".into(), + message: "session unavailable".into(), + } +} + +pub(crate) fn str_param(params: &Value, key: &str) -> Option { + params + .get(key) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} + +/// Synchronous dispatch. Runs under the session lock; must not block. +pub fn dispatch( + session: &SharedSession, + conn: ConnId, + method: &str, + params: &Value, +) -> Result { + dispatch_locked(&mut session.lock(), conn, method, params) +} + +pub(crate) fn dispatch_locked( + s: &mut Session, + conn: ConnId, + method: &str, + params: &Value, +) -> Result { + if s.is_private() { + trace_refusal("session is private", conn, "-"); + return Err(session_unavailable()); + } + if !s.participant_allowed(conn) { + trace_refusal("connection is not a selected participant", conn, "-"); + return Err(session_unavailable()); + } + if s.conn_kind(conn) != Some(ConnKind::Agent) { + return Err(RpcError { + code: "owner_required".into(), + message: "public connections must identify as agents".into(), + }); + } + if !matches!( + method, + "affordances" + | "snapshot" + | "request_control" + | "release_control" + | "type" + | "send_key" + | "interrupt" + | "request_attention" + | "check_approval" + | "control_request_state" + | "proposal_state" + | "exec_state" + | "status" + ) { + return Err(RpcError { + code: "owner_required".into(), + message: "this operation belongs to the local owner UI".into(), + }); + } + if method == "status" { + return Ok( + json!({"controller": s.status().controller, "mode": s.mode(), "effectiveMode": s.effective_mode(), "attended": s.attended(), "shared": !s.is_private(), "surfaceAvailable": s.status().surface_available }), + ); + } + s.note_connection_activity(conn); + let actor = Actor::Agent { conn }; + if matches!( + method, + "check_approval" | "control_request_state" | "proposal_state" | "exec_state" + ) { + let key = match method { + "check_approval" => "approvalId", + "control_request_state" => "requestId", + "proposal_state" => "proposalId", + _ => "execId", + }; + if !s.owns_request(conn, method, params[key].as_str().unwrap_or_default()) { + return Err(session_unavailable()); + } + } + let r: Result = match method { + "affordances" => Ok(json!(s + .affordances(actor) + .iter() + .map(|a| a.name()) + .collect::>())), + "snapshot" => s.snapshot(actor).map(|v| serde_json::to_value(v).unwrap()), + "request_control" => s + .agent_request_control_original(conn, params.clone()) + .map(|o| serde_json::to_value(o).unwrap()), + "control_request_state" => { + let id = str_param(params, "requestId").unwrap_or_default(); + match s.control_request_state(&id) { + Some(state) => Ok(json!({ "requestId": id, "state": state })), + None => Err(SessionError::NotFound(id)), + } + } + "proposal_state" => { + let id = str_param(params, "proposalId").unwrap_or_default(); + match s.proposal_state(&id) { + Some(state) => Ok(json!({ "proposalId": id, "state": state })), + None => Err(SessionError::NotFound(id)), + } + } + "release_control" => s + .agent_release_control(conn) + .map(|_| json!({ "released": true })), + "type" => { + let text = str_param(params, "text").unwrap_or_default(); + s.agent_type(conn, &text) + .map(|_| json!({ "typed": text.chars().count() })) + } + "send_key" => { + let key = str_param(params, "key").unwrap_or_default(); + let intent = str_param(params, "intent"); + s.agent_send_key_with(conn, &key, intent) + .map(|r| serde_json::to_value(r).unwrap()) + } + "interrupt" => s + .agent_interrupt(conn) + .map(|_| json!({ "interrupted": true })), + "check_approval" => { + let id = str_param(params, "approvalId").unwrap_or_default(); + s.check_approval(&id) + .map(|a| serde_json::to_value(a).unwrap()) + } + "exec_state" => { + let id = str_param(params, "execId").unwrap_or_default(); + match s.exec_state(&id) { + Some((state, reason)) => { + Ok(json!({ "execId": id, "state": state, "reason": reason })) + } + None => Err(SessionError::NotFound(id)), + } + } + "request_attention" => s + .agent_request_attention(conn, str_param(params, "reason")) + .map(|_| json!({ "requested": true })), + other => Err(SessionError::InvalidInput(format!( + "unknown method '{other}'" + ))), + }; + r.map_err(RpcError::from) +} diff --git a/crates/core/src/collaboration.rs b/crates/core/src/collaboration.rs new file mode 100644 index 0000000..cfb03d4 --- /dev/null +++ b/crates/core/src/collaboration.rs @@ -0,0 +1,311 @@ +//! Session catalog and cross-session authority. Transport adapters share this coordinator. +use crate::connections::{Admission, AdmissionListener, AdmissionPolicy, ConnectionRegistry}; +use crate::{authority::ConnId, protocol::RpcError, session::SharedSession}; +use serde_json::Value; +use std::sync::Arc; +pub type SessionId = String; + +/// Sessions behind one socket. Exactly one is *attended* — the one the human is +/// looking at — and that is where agents go unless they name a session. +/// Opens a new session (tab) on behalf of an agent: `(agent_id, reason) -> session id`. +/// The host spawns the shell, registers it with the hub and tells its UI. +pub type TabOpener = Arc) -> Result + Send + Sync>; + +pub struct Hub { + sessions: parking_lot::Mutex>, + attended: parking_lot::Mutex>, + opener: parking_lot::Mutex>, + pub(crate) connections: Arc, + control: parking_lot::Mutex<()>, +} + +pub type SharedHub = Arc; + +impl Hub { + pub fn new() -> SharedHub { + Arc::new(Self { + sessions: Default::default(), + attended: Default::default(), + opener: Default::default(), + connections: Arc::new(ConnectionRegistry::default()), + control: Default::default(), + }) + } + pub fn agent_connections(&self) -> Vec { + self.connections.admitted() + } + pub fn pending_admissions(&self) -> Vec { + self.connections.snapshot().pending + } + pub fn admission_snapshot(&self) -> crate::connections::AdmissionSnapshot { + self.connections.snapshot() + } + pub fn admission_policy(&self) -> AdmissionPolicy { + self.connections.policy() + } + pub fn set_admission_policy(&self, policy: AdmissionPolicy) { + self.connections.set_policy(policy); + } + pub fn set_admission_listener(&self, listener: AdmissionListener) { + self.connections.set_listener(listener); + } + pub fn decide_admission(&self, conn: ConnId, allow: bool) -> bool { + self.connections.decide(conn, allow) + } + pub(crate) fn admit( + &self, + conn: ConnId, + name: &str, + changed: tokio::sync::mpsc::UnboundedSender<()>, + ) -> tokio::sync::watch::Receiver { + self.connections.register(conn, name, changed) + } + pub(crate) fn forget_agent(&self, conn: ConnId) { + self.connections.forget(conn); + } + + /// Locks a stable set of sessions for a short authority transition. No wait, + /// PTY I/O loop or UI acknowledgement runs here. Observers cannot see two + /// leases while the new target is committed and the previous work is revoked. + fn control_transition>( + &self, + id: &str, + action: impl FnOnce(&mut crate::Session) -> Result<(ConnId, T), E>, + ) -> Result { + let _control = self.control.lock(); + let sessions = self.sessions.lock().clone(); + let target = sessions + .iter() + .position(|(candidate, _)| candidate == id) + .ok_or_else(|| crate::session::SessionError::NotFound("session unavailable".into()))?; + let mut locked: Vec<_> = sessions.iter().map(|(_, session)| session.lock()).collect(); + let (conn, result) = action(&mut locked[target])?; + for (index, session) in locked.iter_mut().enumerate() { + if index != target { + session.agent_left_tab(conn); + } + } + Ok(result) + } + pub fn request_control( + &self, + id: &str, + conn: ConnId, + params: &Value, + ) -> Result { + self.control_transition(id, |session| { + crate::agent_commands::dispatch_locked(session, conn, "request_control", params) + .map(|result| (conn, result)) + }) + } + pub(crate) fn leave_other_tabs(&self, conn: ConnId, keep: &str) { + let _: Result<(), crate::session::SessionError> = + self.control_transition(keep, |_| Ok((conn, ()))); + } + pub fn decide_control( + &self, + id: &str, + request: &str, + grant: bool, + ) -> Result { + if !grant { + let session = self.get(id).ok_or_else(|| { + crate::session::SessionError::NotFound("session unavailable".into()) + })?; + return session.lock().decide_control(request, false); + } + self.control_transition(id, |session| { + session + .decide_control(request, true) + .map(|result| (result.conn, result)) + }) + } + pub fn hand_back( + &self, + id: &str, + ) -> Result { + self.control_transition(id, |session| { + let info = session.hand_back()?; + Ok((session.controller_conn().expect("hand_back granted"), info)) + }) + } + + pub fn single(id: &str, session: SharedSession) -> SharedHub { + let hub = Self::new(); + hub.add(id, session); + hub + } + + /// Let agents open tabs. Without an opener `open_tab` is unsupported and the + /// tab affordances are not offered. + pub fn set_opener(&self, opener: TabOpener) { + *self.opener.lock() = Some(opener); + for (_, s) in self.sessions.lock().iter() { + s.lock().set_tabs_supported(true); + } + } + + pub fn tabs_supported(&self) -> bool { + self.opener.lock().is_some() + } + + /// Open a tab for `agent_id`. Returns the new session's id. + pub fn open_tab(&self, agent_id: &str, reason: Option<&str>) -> Result { + let opener = self.opener.lock().clone().ok_or_else(|| RpcError { + code: "unsupported".into(), + message: "this host cannot open tabs".into(), + })?; + let id = opener(agent_id, reason).map_err(|m| RpcError { + code: "open_failed".into(), + message: m, + })?; + if self.get_public(&id).is_none() { + return Err(RpcError { + code: "open_failed".into(), + message: "opener did not register an available session".into(), + }); + } + Ok(id) + } + + pub fn add(&self, id: &str, session: SharedSession) { + let first = self.sessions.lock().is_empty(); + session.lock().set_tabs_supported(self.tabs_supported()); + let agents = Arc::downgrade(&self.connections); + session + .lock() + .set_participation_listener(Box::new(move |affected| { + if let Some(agents) = agents.upgrade() { + agents.invalidate(affected); + } + })); + self.sessions.lock().push((id.to_string(), session.clone())); + if first { + *self.attended.lock() = Some(id.to_string()); + session.lock().set_attended(true); + } else { + session.lock().set_attended(false); + } + } + + pub fn remove(&self, id: &str) -> Option { + let mut list = self.sessions.lock(); + let pos = list.iter().position(|(i, _)| i == id)?; + let (_, s) = list.remove(pos); + drop(list); + if self.attended.lock().as_deref() == Some(id) { + let next = self.sessions.lock().first().map(|(i, _)| i.clone()); + drop(self.attended.lock()); + if let Some(n) = next { + self.set_attended(&n); + } else { + *self.attended.lock() = None; + } + } + Some(s) + } + + pub fn ids(&self) -> Vec { + self.sessions + .lock() + .iter() + .map(|(i, _)| i.clone()) + .collect() + } + + pub fn get(&self, id: &str) -> Option { + self.sessions + .lock() + .iter() + .find(|(i, _)| i == id) + .map(|(_, s)| s.clone()) + } + + pub fn attended_id(&self) -> Option { + self.attended.lock().clone() + } + + /// The human now looks at `id`. Every other session becomes unattended. + pub fn set_attended(&self, id: &str) -> bool { + let list: Vec<(SessionId, SharedSession)> = self.sessions.lock().clone(); + if !list.iter().any(|(i, _)| i == id) { + return false; + } + *self.attended.lock() = Some(id.to_string()); + for (i, s) in list { + s.lock().set_attended(i == id); + } + true + } + + /// Public clients cannot enumerate or attach to private native sessions. + pub fn public_ids(&self) -> Vec { + self.sessions + .lock() + .iter() + .filter(|(_, s)| !s.lock().is_private()) + .map(|(id, _)| id.clone()) + .collect() + } + + pub fn get_public(&self, id: &str) -> Option { + self.get(id).filter(|s| !s.lock().is_private()) + } + + pub fn public_attended_id(&self) -> Option { + self.attended_id() + .filter(|id| self.get_public(id).is_some()) + } + + pub(crate) fn public_attended_for(&self, conn: ConnId) -> Option { + self.public_attended_id().filter(|id| { + self.get_public(id) + .is_some_and(|s| s.lock().participant_allowed(conn)) + }) + } + + pub(crate) fn public_ids_for(&self, conn: ConnId) -> Vec { + self.public_ids() + .into_iter() + .filter(|id| { + self.get_public(id) + .is_some_and(|s| s.lock().participant_allowed(conn)) + }) + .collect() + } + + pub(crate) fn public_index_of(&self, id: &str, conn: ConnId) -> Option { + self.public_ids_for(conn) + .iter() + .position(|candidate| candidate == id) + .map(|i| i + 1) + } + + pub(crate) fn find_public_tab( + &self, + tab: &Value, + conn: ConnId, + ) -> Option<(SessionId, SharedSession)> { + let ids = self.public_ids_for(conn); + let id = match tab { + Value::Number(n) => ids.get(n.as_u64()?.checked_sub(1)? as usize), + Value::String(id) => ids.iter().find(|i| *i == id).or_else(|| { + id.parse::() + .ok() + .and_then(|i| ids.get(i.checked_sub(1)?)) + }), + _ => None, + }?; + self.get_public(id).map(|session| (id.clone(), session)) + } + + pub(crate) fn resolve_public( + &self, + session: Option<&str>, + ) -> Option<(SessionId, SharedSession)> { + let id = session + .map(str::to_string) + .or_else(|| self.public_attended_id())?; + self.get_public(&id).map(|s| (id, s)) + } +} diff --git a/crates/core/src/connections.rs b/crates/core/src/connections.rs new file mode 100644 index 0000000..3d8ffd0 --- /dev/null +++ b/crates/core/src/connections.rs @@ -0,0 +1,279 @@ +//! App-wide admission and live connection identity; no sessions or terminal access. +use crate::{authority::ConnId, session::AgentConnection}; +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, sync::Arc}; +struct AgentRegistration { + name: String, + last_seen: std::time::Instant, + catalog_changed: tokio::sync::mpsc::UnboundedSender<()>, + admission: tokio::sync::watch::Sender, +} + +/// Whether a new agent connection participates at once or waits for the owner. +/// Ordinary tabs are open to every admitted connection, so admission is where +/// the owner's explicit consent lives. Embedders default to `Allow`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AdmissionPolicy { + Allow, + Ask, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Admission { + Pending, + Granted, + Denied, +} + +/// `state` is `pending`, `granted`, `denied` or `closed` (left while pending). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdmissionChange { + pub conn_id: ConnId, + pub agent_id: String, + pub state: &'static str, + pub revision: u64, +} + +pub type AdmissionListener = Arc; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdmissionSnapshot { + pub revision: u64, + pub pending: Vec, +} +struct State { + agents: HashMap, + revision: u64, + policy: AdmissionPolicy, +} +pub struct ConnectionRegistry { + state: parking_lot::Mutex, + listener: parking_lot::Mutex>, +} +impl Default for ConnectionRegistry { + fn default() -> Self { + Self { + state: parking_lot::Mutex::new(State { + agents: HashMap::new(), + revision: 0, + policy: AdmissionPolicy::Allow, + }), + listener: Default::default(), + } + } +} +impl ConnectionRegistry { + fn connections(state: &State, admission: Admission) -> Vec { + let mut out: Vec<_> = state + .agents + .iter() + .filter(|(_, a)| *a.admission.borrow() == admission) + .map(|(conn, a)| AgentConnection { + conn_id: *conn, + agent_id: a.name.clone(), + idle_secs: a.last_seen.elapsed().as_secs(), + }) + .collect(); + out.sort_by_key(|a| a.conn_id); + out + } + pub fn admitted(&self) -> Vec { + Self::connections(&self.state.lock(), Admission::Granted) + } + pub fn snapshot(&self) -> AdmissionSnapshot { + let state = self.state.lock(); + AdmissionSnapshot { + revision: state.revision, + pending: Self::connections(&state, Admission::Pending), + } + } + pub fn policy(&self) -> AdmissionPolicy { + self.state.lock().policy + } + pub fn set_listener(&self, listener: AdmissionListener) { + *self.listener.lock() = Some(listener); + } + fn emit(&self, change: AdmissionChange) { + let listener = self.listener.lock().clone(); + if let Some(listener) = listener { + listener(change); + } + } + pub fn set_policy(&self, policy: AdmissionPolicy) { + let events = { + let mut state = self.state.lock(); + state.policy = policy; + let pending = if policy == AdmissionPolicy::Allow { + Self::connections(&state, Admission::Pending) + } else { + Vec::new() + }; + pending + .into_iter() + .filter_map(|a| Self::decide_locked(&mut state, a.conn_id, true)) + .collect::>() + }; + for event in events { + self.emit(event); + } + } + fn decide_locked(state: &mut State, conn: ConnId, allow: bool) -> Option { + let agent = state + .agents + .get(&conn) + .filter(|a| *a.admission.borrow() == Admission::Pending)?; + agent.admission.send_replace(if allow { + Admission::Granted + } else { + Admission::Denied + }); + let _ = agent.catalog_changed.send(()); + state.revision += 1; + Some(AdmissionChange { + conn_id: conn, + agent_id: agent.name.clone(), + state: if allow { "granted" } else { "denied" }, + revision: state.revision, + }) + } + pub fn decide(&self, conn: ConnId, allow: bool) -> bool { + let event = Self::decide_locked(&mut self.state.lock(), conn, allow); + if let Some(event) = event { + self.emit(event); + true + } else { + false + } + } + pub(crate) fn register( + &self, + conn: ConnId, + name: &str, + catalog_changed: tokio::sync::mpsc::UnboundedSender<()>, + ) -> tokio::sync::watch::Receiver { + let (rx, event) = { + let mut state = self.state.lock(); + let admission_state = if state.policy == AdmissionPolicy::Ask { + Admission::Pending + } else { + Admission::Granted + }; + let (admission, rx) = tokio::sync::watch::channel(admission_state); + state.agents.insert( + conn, + AgentRegistration { + name: name.into(), + last_seen: std::time::Instant::now(), + catalog_changed, + admission, + }, + ); + state.revision += 1; + ( + rx, + AdmissionChange { + conn_id: conn, + agent_id: name.into(), + state: if admission_state == Admission::Pending { + "pending" + } else { + "granted" + }, + revision: state.revision, + }, + ) + }; + self.emit(event); + rx + } + pub(crate) fn forget(&self, conn: ConnId) { + let event = { + let mut state = self.state.lock(); + state.agents.remove(&conn).map(|agent| { + state.revision += 1; + AdmissionChange { + conn_id: conn, + agent_id: agent.name, + state: "closed", + revision: state.revision, + } + }) + }; + if let Some(event) = event { + self.emit(event); + } + } + pub(crate) fn touch(&self, conn: ConnId) { + if let Some(agent) = self.state.lock().agents.get_mut(&conn) { + agent.last_seen = std::time::Instant::now(); + } + } + pub(crate) fn invalidate(&self, affected: Option<&std::collections::HashSet>) { + for (conn, agent) in self.state.lock().agents.iter() { + if affected.is_none_or(|ids| ids.contains(conn)) { + let _ = agent.catalog_changed.send(()); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn snapshots_and_events_share_a_revision_and_callbacks_can_read_state() { + let registry = Arc::new(ConnectionRegistry::default()); + registry.set_policy(AdmissionPolicy::Ask); + let weak = Arc::downgrade(®istry); + registry.set_listener(Arc::new(move |event| { + let registry = weak.upgrade().unwrap(); + assert!(registry.snapshot().revision >= event.revision); + })); + let (tx, _) = tokio::sync::mpsc::unbounded_channel(); + let admission = registry.register(7, "same-name", tx); + assert_eq!(*admission.borrow(), Admission::Pending); + assert_eq!(registry.snapshot().revision, 1); + assert!(registry.decide(7, true)); + assert!(!registry.decide(7, false)); + assert_eq!(registry.snapshot().revision, 2); + assert!(registry.snapshot().pending.is_empty()); + registry.forget(7); + assert_eq!(registry.snapshot().revision, 3); + let (tx, _) = tokio::sync::mpsc::unbounded_channel(); + let next = registry.register(8, "same-name", tx); + assert_eq!(*next.borrow(), Admission::Pending); + assert!(registry.admitted().is_empty()); + } + #[test] + fn simultaneous_windows_can_resolve_only_once() { + let registry = Arc::new(ConnectionRegistry::default()); + registry.set_policy(AdmissionPolicy::Ask); + let (tx, _) = tokio::sync::mpsc::unbounded_channel(); + let _rx = registry.register(1, "test", tx); + let barrier = Arc::new(std::sync::Barrier::new(3)); + let threads: Vec<_> = [true, false] + .into_iter() + .map(|allow| { + let (r, b) = (registry.clone(), barrier.clone()); + std::thread::spawn(move || { + b.wait(); + r.decide(1, allow) + }) + }) + .collect(); + barrier.wait(); + assert_eq!( + threads + .into_iter() + .filter_map(|t| t.join().ok()) + .filter(|won| *won) + .count(), + 1 + ); + assert_eq!(registry.snapshot().revision, 2); + } +} diff --git a/crates/core/src/ipc.rs b/crates/core/src/ipc.rs index 2d5b30c..80f347d 100644 --- a/crates/core/src/ipc.rs +++ b/crates/core/src/ipc.rs @@ -16,422 +16,30 @@ use std::sync::mpsc; use std::sync::{Arc, Mutex}; use std::time::Duration; -use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use crate::affordance::{Actor, Affordance}; -use crate::authority::{AuthorityError, ConnId}; -use crate::session::{ConnKind, ControlRequestState, EventSink, ExecState, KeyResult, ProposalState, ServerEvent, SessionError, SharedSession}; - -#[derive(Debug, Serialize, Deserialize)] -pub struct Request { - pub id: u64, - pub method: String, - #[serde(default)] - pub params: Value, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RpcError { - pub code: String, - pub message: String, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct Response { - pub id: u64, - #[serde(skip_serializing_if = "Option::is_none")] - pub result: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -impl From for RpcError { - fn from(e: SessionError) -> Self { - let code = match &e { - SessionError::Authority(AuthorityError::Busy { .. }) => "busy", - SessionError::Authority(AuthorityError::NotController) => "not_controller", - SessionError::Authority(AuthorityError::Expired) => "lease_expired", - SessionError::ProcessExited => "process_exited", - SessionError::InputPending => "input_pending", - SessionError::InvalidInput(_) => "invalid_input", - SessionError::NotFound(_) => "not_found", - SessionError::ApprovalPending(_) => "approval_pending", - SessionError::ExecPending(_) => "exec_pending", - SessionError::RateLimited { .. } => "rate_limited", - SessionError::Masked(_) => "masked", - SessionError::WrongMode(_) => "wrong_mode", - SessionError::ProposalPending(_) => "proposal_pending", - SessionError::IntentRequired => "intent_required", - SessionError::NotAvailable(_) => "not_available", - SessionError::SurfaceUnavailable => "surface_unavailable", - }; - RpcError { code: code.into(), message: e.to_string() } - } -} - -/// Agents get one generic refusal so they cannot probe for private sessions. The -/// owner can ask why: `CONN_TRACE_REFUSALS=1` prints the cause to stderr. Reasons -/// and identifiers only, never terminal content. -fn trace_refusal(reason: &str, conn: ConnId, session: &str) { - static ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); - if *ENABLED.get_or_init(|| std::env::var_os("CONN_TRACE_REFUSALS").is_some_and(|v| !v.is_empty())) { - eprintln!("conn refusal {} conn={conn} session={} reason={reason}", chrono::Local::now().format("%H:%M:%S%.3f"), trace_label(session)); - } -} - -/// The named session or tab comes from the agent. Keep it to one bounded, escaped -/// token so a request cannot forge trace lines or drive the owner's terminal. -fn trace_label(requested: &str) -> String { - let mut label: String = requested.chars().take(96).flat_map(char::escape_default).collect(); - if requested.chars().nth(96).is_some() { label.push('…'); } - label -} - -fn session_unavailable() -> RpcError { - RpcError { code: "not_found".into(), message: "session unavailable".into() } -} - -/// A connection holds a lease and pending work in one tab at a time. Requests may -/// name any permitted `session`, so the binding alone cannot guarantee this: asking -/// for control elsewhere, like moving, gives up whatever is held in the other tabs. -fn leave_other_tabs(hub: &Hub, registered: &[SessionId], conn: ConnId, keep: &str) { - for sid in registered.iter().filter(|sid| sid.as_str() != keep) { - if let Some(s) = hub.get(sid) { s.lock().agent_left_tab(conn); } - } -} +use crate::affordance::Affordance; +use crate::authority::ConnId; +use crate::session::{SessionError, ConnKind, ControlRequestState, EventSink, ExecState, KeyResult, ProposalState, ServerEvent, SharedSession}; +pub use crate::protocol::{Request, Response, RpcError}; +pub use crate::agent_commands::dispatch; +use crate::agent_commands::{trace_refusal, session_unavailable, str_param}; +#[cfg(test)] use crate::agent_commands::trace_label; fn connection_closing() -> RpcError { RpcError { code: "connection_closing".into(), message: "the connection closed before this request could run".into() } } -fn str_param(params: &Value, key: &str) -> Option { - params.get(key).and_then(|v| v.as_str()).map(|s| s.to_string()) -} - -/// Synchronous dispatch. Runs under the session lock; must not block. -pub fn dispatch(session: &SharedSession, conn: ConnId, method: &str, params: &Value) -> Result { - let mut s = session.lock(); - if s.is_private() { trace_refusal("session is private", conn, "-"); return Err(session_unavailable()); } - if !s.participant_allowed(conn) { trace_refusal("connection is not a selected participant", conn, "-"); return Err(session_unavailable()); } - if s.conn_kind(conn) != Some(ConnKind::Agent) { - return Err(RpcError { code: "owner_required".into(), message: "public connections must identify as agents".into() }); - } - if !matches!(method, "affordances" | "snapshot" | "request_control" | "release_control" | "type" | "send_key" | "interrupt" | "request_attention" | "check_approval" | "control_request_state" | "proposal_state" | "exec_state" | "status") { - return Err(RpcError { code: "owner_required".into(), message: "this operation belongs to the local owner UI".into() }); - } - if method == "status" { - return Ok(json!({"controller": s.status().controller, "mode": s.mode(), "effectiveMode": s.effective_mode(), "attended": s.attended(), "shared": !s.is_private(), "surfaceAvailable": s.status().surface_available })); - } - s.note_connection_activity(conn); - let actor = Actor::Agent { conn }; - if matches!(method, "check_approval" | "control_request_state" | "proposal_state" | "exec_state") { - let key = match method { "check_approval" => "approvalId", "control_request_state" => "requestId", "proposal_state" => "proposalId", _ => "execId" }; - if !s.owns_request(conn, method, params[key].as_str().unwrap_or_default()) { return Err(session_unavailable()); } - } - let r: Result = match method { - "affordances" => Ok(json!(s.affordances(actor).iter().map(|a| a.name()).collect::>())), - "snapshot" => s.snapshot(actor).map(|v| serde_json::to_value(v).unwrap()), - "request_control" => { - s.agent_request_control_original(conn, params.clone()).map(|o| serde_json::to_value(o).unwrap()) - } - "control_request_state" => { - let id = str_param(params, "requestId").unwrap_or_default(); - match s.control_request_state(&id) { - Some(state) => Ok(json!({ "requestId": id, "state": state })), - None => Err(SessionError::NotFound(id)), - } - } - "proposal_state" => { - let id = str_param(params, "proposalId").unwrap_or_default(); - match s.proposal_state(&id) { - Some(state) => Ok(json!({ "proposalId": id, "state": state })), - None => Err(SessionError::NotFound(id)), - } - } - "release_control" => s.agent_release_control(conn).map(|_| json!({ "released": true })), - "type" => { - let text = str_param(params, "text").unwrap_or_default(); - s.agent_type(conn, &text).map(|_| json!({ "typed": text.chars().count() })) - } - "send_key" => { - let key = str_param(params, "key").unwrap_or_default(); - let intent = str_param(params, "intent"); - s.agent_send_key_with(conn, &key, intent).map(|r| serde_json::to_value(r).unwrap()) - } - "interrupt" => s.agent_interrupt(conn).map(|_| json!({ "interrupted": true })), - "check_approval" => { - let id = str_param(params, "approvalId").unwrap_or_default(); - s.check_approval(&id).map(|a| serde_json::to_value(a).unwrap()) - } - "exec_state" => { - let id = str_param(params, "execId").unwrap_or_default(); - match s.exec_state(&id) { - Some((state, reason)) => Ok(json!({ "execId": id, "state": state, "reason": reason })), - None => Err(SessionError::NotFound(id)), - } - } - "request_attention" => s.agent_request_attention(conn, str_param(params, "reason")).map(|_| json!({ "requested": true })), - other => Err(SessionError::InvalidInput(format!("unknown method '{other}'"))), - }; - r.map_err(RpcError::from) -} - // --------------------------------------------------------------------------- // Hub: several sessions (tabs) behind one socket // --------------------------------------------------------------------------- -pub type SessionId = String; - -/// Sessions behind one socket. Exactly one is *attended* — the one the human is -/// looking at — and that is where agents go unless they name a session. -/// Opens a new session (tab) on behalf of an agent: `(agent_id, reason) -> session id`. -/// The host spawns the shell, registers it with the hub and tells its UI. -pub type TabOpener = Arc) -> Result + Send + Sync>; - -struct AgentRegistration { - name: String, - last_seen: std::time::Instant, - catalog_changed: tokio::sync::mpsc::UnboundedSender<()>, - admission: tokio::sync::watch::Sender, -} - -/// Whether a new agent connection participates at once or waits for the owner. -/// Ordinary tabs are open to every admitted connection, so admission is where -/// the owner's explicit consent lives. Embedders default to `Allow`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum AdmissionPolicy { Allow, Ask } - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum Admission { Pending, Granted, Denied } - -/// `state` is `pending`, `granted`, `denied` or `closed` (left while pending). -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AdmissionChange { pub conn_id: ConnId, pub agent_id: String, pub state: &'static str } - -pub type AdmissionListener = Arc; - -/// How long a request from a pending connection waits for the owner's decision. +pub use crate::collaboration::{Hub, SharedHub, SessionId, TabOpener}; +pub use crate::connections::{Admission, AdmissionPolicy, AdmissionChange, AdmissionListener}; const ADMISSION_WAIT: Duration = Duration::from_secs(25); - fn admission_error(state: Admission) -> RpcError { - match state { - Admission::Denied => RpcError { code: "admission_denied".into(), message: "the human declined this connection".into() }, - _ => RpcError { code: "admission_pending".into(), message: "waiting for the human to allow this connection in the Conn window".into() }, - } -} - -pub struct Hub { - sessions: parking_lot::Mutex>, - attended: parking_lot::Mutex>, - opener: parking_lot::Mutex>, - agents: Arc>>, - admission_policy: parking_lot::Mutex, - admission_listener: parking_lot::Mutex>, -} - -pub type SharedHub = Arc; - -impl Hub { - pub fn new() -> SharedHub { - Arc::new(Self { sessions: parking_lot::Mutex::new(Vec::new()), attended: parking_lot::Mutex::new(None), opener: parking_lot::Mutex::new(None), agents: Arc::new(parking_lot::Mutex::new(HashMap::new())), - admission_policy: parking_lot::Mutex::new(AdmissionPolicy::Allow), admission_listener: parking_lot::Mutex::new(None) }) - } - - /// Admitted agent identities, including connections waiting for a private session to be shared. - pub fn agent_connections(&self) -> Vec { self.connections_in(Admission::Granted) } - - /// Connections waiting for the owner to allow them. They see and do nothing yet. - pub fn pending_admissions(&self) -> Vec { self.connections_in(Admission::Pending) } - - fn connections_in(&self, state: Admission) -> Vec { - let mut out: Vec<_> = self.agents.lock().iter().filter(|(_, agent)| *agent.admission.borrow() == state).map(|(conn, agent)| crate::session::AgentConnection { - conn_id: *conn, agent_id: agent.name.clone(), idle_secs: agent.last_seen.elapsed().as_secs(), - }).collect(); - out.sort_by_key(|a| a.conn_id); - out - } - - pub fn admission_policy(&self) -> AdmissionPolicy { *self.admission_policy.lock() } - - /// Relaxing the policy admits whoever is already waiting. - pub fn set_admission_policy(&self, policy: AdmissionPolicy) { - *self.admission_policy.lock() = policy; - if policy == AdmissionPolicy::Allow { - for agent in self.pending_admissions() { self.decide_admission(agent.conn_id, true); } - } - } - - pub fn set_admission_listener(&self, listener: AdmissionListener) { *self.admission_listener.lock() = Some(listener); } - - /// The owner's answer for one pending connection. A decision is final for - /// that connection; identity is the live connection, never its display name. - pub fn decide_admission(&self, conn: ConnId, allow: bool) -> bool { - let state = if allow { Admission::Granted } else { Admission::Denied }; - let name = { - let agents = self.agents.lock(); - let Some(agent) = agents.get(&conn).filter(|a| *a.admission.borrow() == Admission::Pending) else { return false; }; - agent.admission.send_replace(state); - let _ = agent.catalog_changed.send(()); - agent.name.clone() - }; - self.admission_changed(conn, name, if allow { "granted" } else { "denied" }); - true - } - - fn admit(&self, conn: ConnId, name: &str, catalog_changed: tokio::sync::mpsc::UnboundedSender<()>) -> tokio::sync::watch::Receiver { - let state = if self.admission_policy() == AdmissionPolicy::Ask { Admission::Pending } else { Admission::Granted }; - let (admission, rx) = tokio::sync::watch::channel(state); - self.agents.lock().insert(conn, AgentRegistration { name: name.into(), last_seen: std::time::Instant::now(), catalog_changed, admission }); - if state == Admission::Pending { self.admission_changed(conn, name.into(), "pending"); } - rx - } - - fn forget_agent(&self, conn: ConnId) { - let left = self.agents.lock().remove(&conn); - if let Some(agent) = left.filter(|a| *a.admission.borrow() == Admission::Pending) { self.admission_changed(conn, agent.name, "closed"); } - } - - fn admission_changed(&self, conn_id: ConnId, agent_id: String, state: &'static str) { - let listener = self.admission_listener.lock().clone(); - if let Some(listener) = listener { listener(AdmissionChange { conn_id, agent_id, state }); } - } - - pub fn single(id: &str, session: SharedSession) -> SharedHub { - let hub = Self::new(); - hub.add(id, session); - hub - } - - /// Let agents open tabs. Without an opener `open_tab` is unsupported and the - /// tab affordances are not offered. - pub fn set_opener(&self, opener: TabOpener) { - *self.opener.lock() = Some(opener); - for (_, s) in self.sessions.lock().iter() { - s.lock().set_tabs_supported(true); - } - } - - pub fn tabs_supported(&self) -> bool { - self.opener.lock().is_some() - } - - /// Open a tab for `agent_id`. Returns the new session's id. - pub fn open_tab(&self, agent_id: &str, reason: Option<&str>) -> Result { - let opener = self.opener.lock().clone().ok_or_else(|| RpcError { code: "unsupported".into(), message: "this host cannot open tabs".into() })?; - let id = opener(agent_id, reason).map_err(|m| RpcError { code: "open_failed".into(), message: m })?; - if self.get_public(&id).is_none() { - return Err(RpcError { code: "open_failed".into(), message: "opener did not register an available session".into() }); - } - Ok(id) - } - - pub fn add(&self, id: &str, session: SharedSession) { - let first = self.sessions.lock().is_empty(); - session.lock().set_tabs_supported(self.tabs_supported()); - let agents = Arc::downgrade(&self.agents); - session.lock().set_participation_listener(Box::new(move |affected| { - if let Some(agents) = agents.upgrade() { - for (conn, agent) in agents.lock().iter() { - if affected.is_none_or(|ids| ids.contains(conn)) { - let _ = agent.catalog_changed.send(()); - } - } - } - })); - self.sessions.lock().push((id.to_string(), session.clone())); - if first { - *self.attended.lock() = Some(id.to_string()); - session.lock().set_attended(true); - } else { - session.lock().set_attended(false); - } - } - - pub fn remove(&self, id: &str) -> Option { - let mut list = self.sessions.lock(); - let pos = list.iter().position(|(i, _)| i == id)?; - let (_, s) = list.remove(pos); - drop(list); - if self.attended.lock().as_deref() == Some(id) { - let next = self.sessions.lock().first().map(|(i, _)| i.clone()); - drop(self.attended.lock()); - if let Some(n) = next { - self.set_attended(&n); - } else { - *self.attended.lock() = None; - } - } - Some(s) - } - - pub fn ids(&self) -> Vec { - self.sessions.lock().iter().map(|(i, _)| i.clone()).collect() - } - - pub fn get(&self, id: &str) -> Option { - self.sessions.lock().iter().find(|(i, _)| i == id).map(|(_, s)| s.clone()) - } - - pub fn attended_id(&self) -> Option { - self.attended.lock().clone() - } - - /// The human now looks at `id`. Every other session becomes unattended. - pub fn set_attended(&self, id: &str) -> bool { - let list: Vec<(SessionId, SharedSession)> = self.sessions.lock().clone(); - if !list.iter().any(|(i, _)| i == id) { - return false; - } - *self.attended.lock() = Some(id.to_string()); - for (i, s) in list { - s.lock().set_attended(i == id); - } - true - } - - /// Public clients cannot enumerate or attach to private native sessions. - pub fn public_ids(&self) -> Vec { - self.sessions.lock().iter().filter(|(_, s)| !s.lock().is_private()).map(|(id, _)| id.clone()).collect() - } - - pub fn get_public(&self, id: &str) -> Option { - self.get(id).filter(|s| !s.lock().is_private()) - } - - pub fn public_attended_id(&self) -> Option { - self.attended_id().filter(|id| self.get_public(id).is_some()) - } - - fn public_attended_for(&self, conn: ConnId) -> Option { - self.public_attended_id().filter(|id| self.get_public(id).is_some_and(|s| s.lock().participant_allowed(conn))) - } - - fn public_ids_for(&self, conn: ConnId) -> Vec { - self.public_ids().into_iter().filter(|id| self.get_public(id).is_some_and(|s| s.lock().participant_allowed(conn))).collect() - } - - fn public_index_of(&self, id: &str, conn: ConnId) -> Option { - self.public_ids_for(conn).iter().position(|candidate| candidate == id).map(|i| i + 1) - } - - fn find_public_tab(&self, tab: &Value, conn: ConnId) -> Option<(SessionId, SharedSession)> { - let ids = self.public_ids_for(conn); - let id = match tab { - Value::Number(n) => ids.get(n.as_u64()?.checked_sub(1)? as usize), - Value::String(id) => ids.iter().find(|i| *i == id).or_else(|| id.parse::().ok().and_then(|i| ids.get(i.checked_sub(1)?))), - _ => None, - }?; - self.get_public(id).map(|session| (id.clone(), session)) - } - - fn resolve_public(&self, session: Option<&str>) -> Option<(SessionId, SharedSession)> { - let id = session.map(str::to_string).or_else(|| self.public_attended_id())?; - self.get_public(&id).map(|s| (id, s)) - } + if state == Admission::Denied { RpcError { code: "admission_denied".into(), message: "the human declined this connection".into() } } + else { RpcError { code: "admission_pending".into(), message: "waiting for the human to allow this connection in the Conn window".into() } } } // --------------------------------------------------------------------------- @@ -623,7 +231,7 @@ async fn handle_conn(stream: crate::transport::Stream, conn: ConnId, hub: Shared for sid in ®istered { if let Some(s) = hub.get_public(sid) { s.lock().note_connection_activity(conn); } } - if let Some(agent) = hub.agents.lock().get_mut(&conn) { agent.last_seen = std::time::Instant::now(); } + hub.connections.touch(conn); let mut disclosure = str_param(&req.params, "session").or_else(|| bound.clone()).or_else(|| hub.public_attended_for(conn)) .and_then(|id| hub.get(&id).map(|s| { let generation = s.lock().participation_generation(); (id, generation, None::) })); let read_only = matches!(req.method.as_str(), "hello" | "sessions" | "list_tabs" | "affordances" | "navigation_affordances" @@ -676,7 +284,7 @@ async fn handle_conn(stream: crate::transport::Stream, conn: ConnId, hub: Shared } else { if let Some((sid, s)) = hub.resolve_public(str_param(&req.params, "session").as_deref()).filter(|(_, s)| s.lock().participant_allowed(conn)) { register(&sid, &s, &identity, &mut registered); - bound = Some(sid); + bound = Some(sid.clone()); } let target = bound.clone().or_else(|| hub.public_attended_for(conn)); let modes = target.as_deref().and_then(|id| hub.get_public(id)).map(|s| { let s = s.lock(); (s.mode(), s.effective_mode()) }); @@ -706,7 +314,7 @@ async fn handle_conn(stream: crate::transport::Stream, conn: ConnId, hub: Shared Ok(sid) => { let ns = hub.get_public(&sid).expect("checked"); register(&sid, &ns, &identity, &mut registered); - leave_other_tabs(&hub, ®istered, conn, &sid); + hub.leave_other_tabs(conn, &sid); ns.lock().agent_opened_tab(conn, reason); bound = Some(sid.clone()); Ok(json!({ "session": sid, "tab": hub.public_index_of(&sid, conn), "attended": false, "note": "the human is not looking at this tab yet; request_attention is queued — wait for the human to show this tab" })) @@ -735,7 +343,7 @@ async fn handle_conn(stream: crate::transport::Stream, conn: ConnId, hub: Shared Err(e) => Err(e.into()), Ok(()) => { register(&to, &ts, &identity, &mut registered); - leave_other_tabs(&hub, ®istered, conn, &to); + hub.leave_other_tabs(conn, &to); if let Some(from) = bound.as_ref().filter(|from| **from != to) { if let Some(source) = hub.get_public(from) { let mut source = source.lock(); @@ -766,11 +374,10 @@ async fn handle_conn(stream: crate::transport::Stream, conn: ConnId, hub: Shared match hub.resolve_public(target.as_deref()) { Some((sid, s)) => { register(&sid, &s, &identity, &mut registered); - if req.method == "request_control" { leave_other_tabs(&hub, ®istered, conn, &sid); } if bound.is_none() { - bound = Some(sid); + bound = Some(sid.clone()); } - call_with_pacing(&s, conn, &req.method, &req.params).await + call_with_pacing(&hub, &sid, &s, conn, &req.method, &req.params).await } None => { trace_refusal("no such shared session (closed, private, or never bound)", conn, target.as_deref().unwrap_or("-")); Err(session_unavailable()) } } @@ -809,19 +416,23 @@ async fn handle_conn(stream: crate::transport::Stream, conn: ConnId, hub: Shared /// Dispatch, absorbing pacing on behalf of the agent: rate limits become waits, and a /// scheduled ENTER is awaited until it executes or is cancelled. -async fn call_with_pacing(session: &SharedSession, conn: ConnId, method: &str, params: &Value) -> Result { +async fn call_with_pacing(hub: &Hub, id: &str, session: &SharedSession, conn: ConnId, method: &str, params: &Value) -> Result { if method == "send_key" && str_param(params, "key").map(|k| k.eq_ignore_ascii_case("ENTER")).unwrap_or(false) { // Let the shell echo settle so the VT cursor line reflects the typed text. tokio::time::sleep(Duration::from_millis(60)).await; } let initial_generation = session.lock().participation_generation(); - let mut result = dispatch(session, conn, method, params); + let dispatch_request = || { + if method == "request_control" { hub.request_control(id, conn, params) } + else { dispatch(session, conn, method, params) } + }; + let mut result = dispatch_request(); for _ in 0..200 { match &result { Err(e) if e.code == "rate_limited" => { let ms = e.message.split_whitespace().rev().nth(1).and_then(|s| s.parse::().ok()).unwrap_or(50); tokio::time::sleep(Duration::from_millis(ms.clamp(5, 5000))).await; - result = dispatch(session, conn, method, params); + result = dispatch_request(); } _ => break, } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index e36c184..2cb6dd5 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -24,6 +24,10 @@ pub mod authority; pub mod config; pub mod engine; pub mod input; +pub mod protocol; +pub mod agent_commands; +pub mod connections; +pub mod collaboration; pub mod ipc; mod transport; pub mod paths; diff --git a/crates/core/src/protocol.rs b/crates/core/src/protocol.rs new file mode 100644 index 0000000..a7e3306 --- /dev/null +++ b/crates/core/src/protocol.rs @@ -0,0 +1,54 @@ +//! Transport-neutral request/result contracts shared by adapters. +use crate::{authority::AuthorityError, session::SessionError}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Request { + pub id: u64, + pub method: String, + #[serde(default)] + pub params: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RpcError { + pub code: String, + pub message: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Response { + pub id: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl From for RpcError { + fn from(e: SessionError) -> Self { + let code = match &e { + SessionError::Authority(AuthorityError::Busy { .. }) => "busy", + SessionError::Authority(AuthorityError::NotController) => "not_controller", + SessionError::Authority(AuthorityError::Expired) => "lease_expired", + SessionError::ProcessExited => "process_exited", + SessionError::InputPending => "input_pending", + SessionError::InvalidInput(_) => "invalid_input", + SessionError::NotFound(_) => "not_found", + SessionError::ApprovalPending(_) => "approval_pending", + SessionError::ExecPending(_) => "exec_pending", + SessionError::RateLimited { .. } => "rate_limited", + SessionError::Masked(_) => "masked", + SessionError::WrongMode(_) => "wrong_mode", + SessionError::ProposalPending(_) => "proposal_pending", + SessionError::IntentRequired => "intent_required", + SessionError::NotAvailable(_) => "not_available", + SessionError::SurfaceUnavailable => "surface_unavailable", + }; + RpcError { + code: code.into(), + message: e.to_string(), + } + } +} diff --git a/crates/core/src/session.rs b/crates/core/src/session.rs index f838de2..2bf0636 100644 --- a/crates/core/src/session.rs +++ b/crates/core/src/session.rs @@ -8,6 +8,10 @@ //! talks to the outside world through two byte sinks (PTY in, human output out) and //! `EventSink`s registered per connection. +mod participation; +mod pending_work; +use participation::Participation; + use std::collections::{HashMap, HashSet}; use std::io::Write; use std::sync::{Arc, Mutex}; @@ -343,6 +347,8 @@ pub struct Status { pub size: Size, pub pending: Vec, pub scheduled: Option, + #[serde(rename = "scheduledRemainingMs")] + pub scheduled_remaining_ms: Option, #[serde(rename = "sessionAllows")] pub session_allows: Vec, #[serde(rename = "policyPath")] @@ -355,7 +361,7 @@ pub struct Status { #[serde(rename = "affordanceMask")] pub affordance_mask: Option>, pub mode: AgentMode, - /// Effective mode; unobserved sessions have no input authority. + /// Effective mode is independent of window focus and observation. #[serde(rename = "effectiveMode")] pub effective_mode: AgentMode, pub attended: bool, @@ -417,11 +423,9 @@ type ParticipationListener = Box>) + Send + Sync> pub struct Session { // Origin survives participation transitions. Only native owners change sharing. external_origin: bool, - shared: bool, - participants: Option>, + participation: Participation, participation_listener: Option, surface_generation: u64, - participation_generation: u64, output_seq: u64, surface_id: String, external_writer_active: bool, @@ -514,11 +518,9 @@ impl Session { fn with_origin(cfg: SessionConfig, external_private: bool) -> Self { Self { external_origin: external_private, - shared: !external_private, - participants: if external_private { Some(HashSet::new()) } else { None }, + participation: Participation::new(external_private), participation_listener: None, surface_generation: 1, - participation_generation: 1, output_seq: 0, surface_id: uuid::Uuid::new_v4().to_string(), external_writer_active: external_private, @@ -570,17 +572,17 @@ impl Session { } } - pub fn is_private(&self) -> bool { !self.shared } + pub fn is_private(&self) -> bool { !self.participation.shared } pub fn external_origin(&self) -> bool { self.external_origin } pub fn surface_generation(&self) -> u64 { self.surface_generation } - pub fn participation_generation(&self) -> u64 { self.participation_generation } + pub fn participation_generation(&self) -> u64 { self.participation.generation } pub fn output_seq(&self) -> u64 { self.output_seq } /// Native owner installs a log at the moment sharing is enabled, never at external startup. - pub fn activate_shared_audit(&mut self, audit: Audit) { audit.set_enabled(self.shared); self.audit = audit; } + pub fn activate_shared_audit(&mut self, audit: Audit) { audit.set_enabled(self.participation.shared); self.audit = audit; } pub fn set_output_frame_sink(&mut self, sink: Option) { self.output_frame_sink = sink; } pub fn participant_allowed(&self, conn: ConnId) -> bool { - self.shared && self.participants.as_ref().is_none_or(|ids| ids.contains(&conn)) + self.participation.allows(conn) } pub fn review_required(&self) -> bool { @@ -589,7 +591,7 @@ impl Session { } pub fn completion_prompt_ready(&self) -> bool { - self.shared && self.process_alive && self.shell_integration.state == "active" && self.shell_command.is_none() + self.participation.shared && self.process_alive && self.shell_integration.state == "active" && self.shell_command.is_none() && self.shell_prompt_confirmed && !self.shell_submission_inflight && !self.external_origin && self.authority.controller().is_human() } @@ -608,75 +610,6 @@ impl Session { }) } - /// Only the local owner may add/remove agent participation. The PTY is untouched. - pub fn set_shared(&mut self, shared: bool) -> Result<(), SessionError> { - self.set_shared_with_agents(shared, Vec::new()) - } - - /// Host-only catalog invalidation. The listener must not lock any session. - pub(crate) fn set_participation_listener(&mut self, listener: ParticipationListener) { - self.participation_listener = Some(listener); - } - - pub fn set_shared_with_agents(&mut self, shared: bool, agents: Vec) -> Result<(), SessionError> { - if agents.len() > 64 { return Err(SessionError::InvalidInput("at most 64 participants".into())); } - let selected: HashSet<_> = agents.into_iter().collect(); - if !self.process_alive { return Err(SessionError::ProcessExited); } - if self.shared == shared && self.participants.as_ref() == Some(&selected) { return Ok(()); } - if shared && (self.input.has_pending() || self.human_input_pending) { return Err(SessionError::InputPending); } - let affected = if self.shared && self.participants.is_none() { - None - } else { - let mut ids = if self.shared { self.participants.clone().unwrap_or_default() } else { HashSet::new() }; - if shared { ids.extend(selected.iter().copied()); } - Some(ids) - }; - // Revocation cannot be held hostage by a partial agent write. Preserve the - // physical input for the owner instead of guessing a clear key in a TUI. - let unfinished_agent_input = self.input.has_pending(); - self.revoke_external(); - self.cancel_agent_work("sharing_changed"); - self.human_input_pending |= unfinished_agent_input && self.input.has_pending(); - if !shared { self.audit.record("human", "sharing_stopped", json!({ "generation": self.surface_generation })); } - self.audit.set_enabled(shared); - self.shared = shared; - self.participation_generation = self.participation_generation.saturating_add(1); - self.participants = Some(selected); - self.surface_generation = self.surface_generation.saturating_add(1); - self.input.reset(); - self.shell_submissions.clear(); - self.shell_command = None; - self.shell_prompt_confirmed = false; - self.shell_recording_ready = false; - self.shell_recording_armed = false; - self.shell_human_input = false; - self.shell_agent_input = false; - self.last_agent = None; - self.last_agent_cmd = None; - self.attention_request = None; - if shared { self.audit.record("human", "sharing_started", json!({ "generation": self.surface_generation })); } - self.broadcast(ServerEvent::SharingChanged { shared, generation: self.surface_generation }); - self.notify_tools_changed(); - // New participants have no session subscription yet; removed participants' - // session events are deliberately discarded by the transport's ACL guard. - if let Some(listener) = &self.participation_listener { listener(affected.as_ref()); } - Ok(()) - } - - fn cancel_agent_work(&mut self, reason: &str) { - self.cancel_scheduled_if(|_| true, reason); - self.reject_proposal_if(|_| true, reason); - let ids: Vec<_> = self.approvals.pending().into_iter().map(|a| a.id.clone()).collect(); - for id in ids { self.finish_approval(&id, ApprovalState::Denied, reason); } - let ids: Vec<_> = self.control_requests.iter().filter(|r| r.state == ControlRequestState::Pending).map(|r| r.request_id.clone()).collect(); - for id in ids { self.finish_control_request(&id, ControlRequestState::Denied); } - if let Some(lease) = self.authority.revoke() { - let ev = ServerEvent::ControlRevoked { lease: lease.label(), agent_id: lease.agent_id, reason: RevokeReason::Taken }; - self.notify(lease.conn, ev.clone()); - self.broadcast(ev); - } - } - pub(crate) fn shell_integration_starting(&mut self) { if self.is_private() { return; } self.shell_prompt_confirmed = false; @@ -696,7 +629,7 @@ impl Session { fn shell_submission(&mut self, actor: &str, command: &str) -> Option { self.shell_agent_input = true; - self.shell_recording_armed |= self.shared && self.shell_recording_ready && self.shell_prompt_confirmed; + self.shell_recording_armed |= self.participation.shared && self.shell_recording_ready && self.shell_prompt_confirmed; if self.is_private() || self.shell_integration.state != "active" || self.shell_command.is_some() { return None; } let id = uuid::Uuid::new_v4().to_string(); if self.shell_submissions.len() >= 16 { self.shell_submissions.remove(0); } @@ -710,14 +643,14 @@ impl Session { // 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 record_start = self.shared && self.shell_recording_ready && self.shell_recording_armed; + let record_start = self.participation.shared && self.shell_recording_ready && self.shell_recording_armed; match kind { "ready" => { self.shell_prompt_confirmed = !self.shell_submission_inflight; self.shell_integration.state = "active".into(); self.shell_integration.shell = Some(text.into()); self.shell_integration.reason = None; - self.shell_recording_ready = self.shared && self.shell_prompt_confirmed; + self.shell_recording_ready = self.participation.shared && self.shell_prompt_confirmed; } "start" => { self.shell_foreground_sequence = Some(sequence); @@ -729,7 +662,7 @@ impl Session { self.shell_foreground_sequence = None; self.shell_submission_inflight = false; self.shell_prompt_confirmed = true; - self.shell_recording_ready = self.shared && self.shell_integration.state == "active"; + self.shell_recording_ready = self.participation.shared && self.shell_integration.state == "active"; self.shell_recording_armed = false; } "gap" | "unavailable" => { @@ -901,14 +834,14 @@ impl Session { fn notify(&self, conn: ConnId, ev: ServerEvent) { if let Some(c) = self.conns.get(&conn) { if c.kind == ConnKind::Agent && !self.participant_allowed(conn) { return; } - c.sink.send_scoped(ev, self.participation_generation); + c.sink.send_scoped(ev, self.participation.generation); } } fn notify_tools_changed(&self) { for c in self.conns.values() { if c.kind == ConnKind::Agent { - c.sink.send_scoped(ServerEvent::ToolsChanged, self.participation_generation); + c.sink.send_scoped(ServerEvent::ToolsChanged, self.participation.generation); } } } @@ -917,7 +850,7 @@ impl Session { fn broadcast(&self, ev: ServerEvent) { for c in self.conns.values() { if c.kind == ConnKind::Frontend { - c.sink.send_scoped(ev.clone(), self.participation_generation); + c.sink.send_scoped(ev.clone(), self.participation.generation); } } } @@ -980,14 +913,7 @@ impl Session { self.broadcast(ServerEvent::ControlRevoked { lease: lease.label(), agent_id: lease.agent_id, reason: RevokeReason::Disconnected }); self.notify_tools_changed(); } - self.cancel_scheduled_if(|s| s.conn == conn, "agent_disconnected"); - self.reject_proposal_if(|p| p.conn == conn, "agent_disconnected"); - for id in self.control_requests.iter().filter(|r| r.conn == conn && r.state == ControlRequestState::Pending).map(|r| r.request_id.clone()).collect::>() { - self.finish_control_request(&id, ControlRequestState::Denied); - } - for id in self.approvals.pending_for_conn(conn) { - self.finish_approval(&id, ApprovalState::Denied, "agent_disconnected"); - } + self.cancel_pending_work(Some(conn), "agent_disconnected"); if let Some(c) = self.conns.remove(&conn) { if c.kind == ConnKind::Agent { self.audit.record(&name, "disconnect", json!({ "conn": conn })); @@ -1050,7 +976,7 @@ impl Session { /// Revoke the agent lease without typing anything (`conn take`, frontend button). pub fn human_take(&mut self) -> Option { self.revoke_external(); - self.cancel_scheduled_if(|_| true, "taken"); + self.cancel_pending_work(None, "taken"); let lease = self.authority.revoke()?; let ev = ServerEvent::ControlRevoked { lease: lease.label(), agent_id: lease.agent_id.clone(), reason: RevokeReason::Taken }; self.notify(lease.conn, ev.clone()); @@ -1082,15 +1008,12 @@ impl Session { self.finish_shell_command(None); self.revoke_external(); self.process_alive = false; - self.cancel_scheduled_if(|_| true, "process_exited"); + self.cancel_pending_work(None, "process_exited"); if let Some(lease) = self.authority.revoke() { let ev = ServerEvent::ControlRevoked { lease: lease.label(), agent_id: lease.agent_id, reason: RevokeReason::ProcessExited }; self.notify(lease.conn, ev.clone()); self.broadcast(ev); } - for a in self.approvals.pending().iter().map(|a| a.id.clone()).collect::>() { - self.finish_approval(&a, ApprovalState::Denied, "process_exited"); - } self.notify_tools_changed(); self.broadcast(ServerEvent::ProcessExited { exit_code }); self.audit.record("system", "session_end", json!({ "exitCode": exit_code })); @@ -1175,14 +1098,7 @@ impl Session { /// tab: nothing stays held or queued here by an agent that has left. pub fn agent_left_tab(&mut self, conn: ConnId) { if self.is_private() { return; } - self.cancel_scheduled_if(|s| s.conn == conn, "agent_left_tab"); - self.reject_proposal_if(|p| p.conn == conn, "agent_left_tab"); - for id in self.control_requests.iter().filter(|r| r.conn == conn && r.state == ControlRequestState::Pending).map(|r| r.request_id.clone()).collect::>() { - self.finish_control_request(&id, ControlRequestState::Denied); - } - for id in self.approvals.pending_for_conn(conn) { - self.finish_approval(&id, ApprovalState::Denied, "agent_left_tab"); - } + self.cancel_pending_work(Some(conn), "agent_left_tab"); if let Some(lease) = self.authority.revoke_if_held_by(conn) { self.audit.record(&lease.agent_id, "lease_released", json!({ "lease": lease.label(), "reason": "left_tab" })); self.broadcast(ServerEvent::ControlRevoked { lease: lease.label(), agent_id: lease.agent_id, reason: RevokeReason::Released }); @@ -1416,6 +1332,8 @@ impl Session { } /// Give control back to the agent that last held it (human action). + pub(crate) fn controller_conn(&self) -> Option { self.authority.controller().lease().map(|lease| lease.conn) } + pub fn hand_back(&mut self) -> Result { let (conn, agent_id) = self.last_agent.clone().ok_or_else(|| SessionError::NotFound("no previous agent".into()))?; if !self.conns.contains_key(&conn) { @@ -1440,16 +1358,13 @@ impl Session { self.mode = mode; match mode { AgentMode::Observe => { - self.reject_proposal_if(|_| true, "mode_changed"); - self.cancel_scheduled_if(|_| true, "mode_changed"); + self.cancel_pending_work(None, "mode_changed"); if let Some(lease) = self.authority.revoke() { let ev = ServerEvent::ControlRevoked { lease: lease.label(), agent_id: lease.agent_id, reason: RevokeReason::Taken }; self.notify(lease.conn, ev.clone()); self.broadcast(ev); } - for id in self.control_requests.iter().filter(|r| r.state == ControlRequestState::Pending).map(|r| r.request_id.clone()).collect::>() { - self.finish_control_request(&id, ControlRequestState::Denied); - } + } AgentMode::Copilot => { self.cancel_scheduled_if(|_| true, "mode_changed"); @@ -1467,7 +1382,7 @@ impl Session { fn notify_mode_changed(&self) { let ev = ServerEvent::ModeChanged { mode: self.mode, effective_mode: self.effective_mode() }; self.broadcast(ev.clone()); - for c in self.conns.values().filter(|c| c.kind == ConnKind::Agent) { c.sink.send_scoped(ev.clone(), self.participation_generation); } + for c in self.conns.values().filter(|c| c.kind == ConnKind::Agent) { c.sink.send_scoped(ev.clone(), self.participation.generation); } } pub fn mode(&self) -> AgentMode { @@ -1608,7 +1523,7 @@ impl Session { pub fn agent_release_control(&mut self, conn: ConnId) -> Result<(), SessionError> { self.require_participant(conn)?; let lease = self.authority.revoke_if_held_by(conn).ok_or(AuthorityError::NotController)?; - self.cancel_scheduled_if(|s| s.conn == conn, "released"); + self.cancel_pending_work(Some(conn), "released"); self.audit.record(&lease.agent_id, "lease_released", json!({ "lease": lease.label() })); self.broadcast(ServerEvent::ControlRevoked { lease: lease.label(), agent_id: lease.agent_id, reason: RevokeReason::Released }); self.notify_tools_changed(); @@ -2016,7 +1931,7 @@ impl Session { for id in self.control_requests.iter().filter(|r| r.state == ControlRequestState::Pending && now.duration_since(r.created) >= ttl).map(|r| r.request_id.clone()).collect::>() { self.finish_control_request(&id, ControlRequestState::Expired); } - let surface_ready = self.shared; + let surface_ready = self.participation.shared; if let Some(s) = &mut self.scheduled { if surface_ready && s.state == ExecState::Scheduled && s.due <= now { s.state = ExecState::Executed; @@ -2047,11 +1962,11 @@ impl Session { connected_agents.dedup(); Status { shell_integration: self.shell_integration.clone(), - shared: self.shared, + shared: self.participation.shared, external_origin: self.external_origin, surface_generation: self.surface_generation, output_seq: self.output_seq, - surface_available: self.shared, + surface_available: self.participation.shared, completion_prompt_ready: self.completion_prompt_ready(), input_pending: self.human_input_pending || self.input.has_pending(), external_input_available: self.external_writer_active(), @@ -2064,6 +1979,7 @@ impl Session { size: self.screen.size(), pending: self.approvals.pending().into_iter().cloned().collect(), scheduled: self.scheduled_exec().cloned(), + scheduled_remaining_ms: self.scheduled_exec().map(|s| s.due.saturating_duration_since(Instant::now()).as_millis() as u64), session_allows: self.policy.session_allows(), policy_path: self.policy.path().map(|p| p.display().to_string()), connected_agents, diff --git a/crates/core/src/session/participation.rs b/crates/core/src/session/participation.rs new file mode 100644 index 0000000..a71fbc6 --- /dev/null +++ b/crates/core/src/session/participation.rs @@ -0,0 +1,143 @@ +//! Audience state lives inside the Session lock. It never grants a write lease. +use crate::authority::ConnId; +use std::collections::HashSet; +pub(super) struct Participation { + pub shared: bool, + pub selected: Option>, + pub generation: u64, +} +impl Participation { + pub fn new(private: bool) -> Self { + Self { + shared: !private, + selected: if private { Some(HashSet::new()) } else { None }, + generation: 1, + } + } + pub fn allows(&self, conn: ConnId) -> bool { + self.shared && self.selected.as_ref().is_none_or(|ids| ids.contains(&conn)) + } + pub fn replace(&mut self, shared: bool, selected: HashSet) { + self.shared = shared; + self.selected = Some(selected); + self.generation = self.generation.saturating_add(1); + } +} + +use super::{ParticipationListener, RevokeReason, ServerEvent, Session, SessionError}; +use serde_json::json; +impl Session { + /// Only the local owner may add/remove agent participation. The PTY is untouched. + pub fn set_shared(&mut self, shared: bool) -> Result<(), SessionError> { + self.set_shared_with_agents(shared, Vec::new()) + } + + /// Host-only catalog invalidation. The listener must not lock any session. + pub(crate) fn set_participation_listener(&mut self, listener: ParticipationListener) { + self.participation_listener = Some(listener); + } + + /// Side-effect-free preflight, used while the owner holds the writer fence. + pub fn validate_sharing(&self, shared: bool, agents: &[ConnId]) -> Result<(), SessionError> { + if agents.len() > 64 { + return Err(SessionError::InvalidInput("at most 64 participants".into())); + } + if !self.process_alive { + return Err(SessionError::ProcessExited); + } + let selected: HashSet<_> = agents.iter().copied().collect(); + if self.participation.shared == shared + && self.participation.selected.as_ref() == Some(&selected) + { + return Ok(()); + } + if shared && (self.input.has_pending() || self.human_input_pending) { + return Err(SessionError::InputPending); + } + Ok(()) + } + pub fn set_shared_with_agents( + &mut self, + shared: bool, + agents: Vec, + ) -> Result<(), SessionError> { + self.validate_sharing(shared, &agents)?; + let selected: HashSet<_> = agents.into_iter().collect(); + if self.participation.shared == shared + && self.participation.selected.as_ref() == Some(&selected) + { + return Ok(()); + } + let affected = if self.participation.shared && self.participation.selected.is_none() { + None + } else { + let mut ids = if self.participation.shared { + self.participation.selected.clone().unwrap_or_default() + } else { + HashSet::new() + }; + if shared { + ids.extend(selected.iter().copied()); + } + Some(ids) + }; + // Revocation cannot be held hostage by a partial agent write. Preserve the + // physical input for the owner instead of guessing a clear key in a TUI. + let unfinished_agent_input = self.input.has_pending(); + self.revoke_external(); + self.cancel_agent_work("sharing_changed"); + self.human_input_pending |= unfinished_agent_input && self.input.has_pending(); + if !shared { + self.audit.record( + "human", + "sharing_stopped", + json!({ "generation": self.surface_generation }), + ); + } + self.audit.set_enabled(shared); + self.participation.replace(shared, selected); + self.surface_generation = self.surface_generation.saturating_add(1); + self.input.reset(); + self.shell_submissions.clear(); + self.shell_command = None; + self.shell_prompt_confirmed = false; + self.shell_recording_ready = false; + self.shell_recording_armed = false; + self.shell_human_input = false; + self.shell_agent_input = false; + self.last_agent = None; + self.last_agent_cmd = None; + self.attention_request = None; + if shared { + self.audit.record( + "human", + "sharing_started", + json!({ "generation": self.surface_generation }), + ); + } + self.broadcast(ServerEvent::SharingChanged { + shared, + generation: self.surface_generation, + }); + self.notify_tools_changed(); + // New participants have no session subscription yet; removed participants' + // session events are deliberately discarded by the transport's ACL guard. + if let Some(listener) = &self.participation_listener { + listener(affected.as_ref()); + } + Ok(()) + } + + fn cancel_agent_work(&mut self, reason: &str) { + self.cancel_pending_work(None, reason); + if let Some(lease) = self.authority.revoke() { + let ev = ServerEvent::ControlRevoked { + lease: lease.label(), + agent_id: lease.agent_id, + reason: RevokeReason::Taken, + }; + self.notify(lease.conn, ev.clone()); + self.broadcast(ev); + } + } +} diff --git a/crates/core/src/session/pending_work.rs b/crates/core/src/session/pending_work.rs new file mode 100644 index 0000000..f2e8480 --- /dev/null +++ b/crates/core/src/session/pending_work.rs @@ -0,0 +1,32 @@ +//! Common cancellation, retaining the distinct review/proposal/grace lifecycles. +//! Runs under Session's lock; never creates its own consistency boundary. +use super::*; +impl Session { + pub(super) fn cancel_pending_work(&mut self, conn: Option, reason: &str) { + self.cancel_scheduled_if(|s| conn.is_none_or(|id| s.conn == id), reason); + self.reject_proposal_if(|p| conn.is_none_or(|id| p.conn == id), reason); + let approvals: Vec<_> = match conn { + Some(id) => self.approvals.pending_for_conn(id), + None => self + .approvals + .pending() + .into_iter() + .map(|a| a.id.clone()) + .collect(), + }; + for id in approvals { + self.finish_approval(&id, ApprovalState::Denied, reason); + } + let controls: Vec<_> = self + .control_requests + .iter() + .filter(|r| { + r.state == ControlRequestState::Pending && conn.is_none_or(|id| r.conn == id) + }) + .map(|r| r.request_id.clone()) + .collect(); + for id in controls { + self.finish_control_request(&id, ControlRequestState::Denied); + } + } +} diff --git a/crates/core/tests/collaboration.rs b/crates/core/tests/collaboration.rs new file mode 100644 index 0000000..e862534 --- /dev/null +++ b/crates/core/tests/collaboration.rs @@ -0,0 +1,117 @@ +mod common; +use common::{Harness, SessionExt}; +use conn_core::{ + collaboration::Hub, + session::{AgentMode, ControlOutcome, ControllerInfo}, +}; +use serde_json::json; +use std::sync::Arc; +fn pair() -> (Arc, conn_core::SharedSession, conn_core::SharedSession) { + let mut a = Harness::new(); + let mut b = Harness::new(); + a.agent(7, "agent"); + b.agent(7, "agent"); + let a = Arc::new(parking_lot::Mutex::new(a.session)); + let b = Arc::new(parking_lot::Mutex::new(b.session)); + let hub = Hub::new(); + hub.add("a", a.clone()); + hub.add("b", b.clone()); + (hub, a, b) +} +#[test] +fn owner_grant_and_handback_share_cross_session_exclusivity() { + let (hub, a, b) = pair(); + a.lock().agent_request_control(7).unwrap(); + b.lock().set_control_gate(true); + let ControlOutcome::Pending { request_id } = b + .lock() + .agent_request_control_original(7, json!({})) + .unwrap() + else { + panic!() + }; + hub.decide_control("b", &request_id, true).unwrap(); + assert!(matches!( + a.lock().status().controller, + ControllerInfo::Human + )); + assert!(matches!( + b.lock().status().controller, + ControllerInfo::Agent { .. } + )); + hub.hand_back("a").unwrap(); + assert!(matches!( + a.lock().status().controller, + ControllerInfo::Agent { .. } + )); + assert!(matches!( + b.lock().status().controller, + ControllerInfo::Human + )); +} +#[test] +fn rejected_target_preserves_the_previous_lease() { + let (hub, a, b) = pair(); + a.lock().agent_request_control(7).unwrap(); + b.lock().set_mode(AgentMode::Observe).unwrap(); + assert!(hub.request_control("b", 7, &json!({})).is_err()); + assert!(matches!( + a.lock().status().controller, + ControllerInfo::Agent { .. } + )); +} +#[test] +fn simultaneous_owner_handbacks_leave_exactly_one_controller() { + let (hub, a, b) = pair(); + a.lock().agent_request_control(7).unwrap(); + a.lock().human_take(); + b.lock().agent_request_control(7).unwrap(); + b.lock().human_take(); + let barrier = Arc::new(std::sync::Barrier::new(3)); + let threads: Vec<_> = ["a", "b"] + .into_iter() + .map(|id| { + let (h, b) = (hub.clone(), barrier.clone()); + std::thread::spawn(move || { + b.wait(); + h.hand_back(id).unwrap(); + }) + }) + .collect(); + barrier.wait(); + for thread in threads { + thread.join().unwrap(); + } + assert_eq!( + [a, b] + .iter() + .filter(|s| matches!(s.lock().status().controller, ControllerInfo::Agent { .. })) + .count(), + 1 + ); +} +#[test] +fn takeover_and_exit_end_pending_control_requests() { + for exit in [false, true] { + let mut h = Harness::new(); + h.agent(7, "agent"); + h.session.set_control_gate(true); + let ControlOutcome::Pending { request_id } = h + .session + .agent_request_control_original(7, json!({})) + .unwrap() + else { + panic!() + }; + if exit { + h.session.process_exited(Some(0)); + } else { + h.session.human_take(); + } + assert_eq!( + h.session.control_request_state(&request_id), + Some(conn_core::session::ControlRequestState::Denied) + ); + assert!(h.session.decide_control(&request_id, true).is_err()); + } +} diff --git a/crates/frontend/src/automation.rs b/crates/frontend/src/automation.rs index 269393b..d261286 100644 --- a/crates/frontend/src/automation.rs +++ b/crates/frontend/src/automation.rs @@ -48,6 +48,10 @@ struct Binding { impl Binding { fn stop(&self) { let _operation = self.operation.lock(); + self.stop_locked(); + } + // Caller holds operation; no worker can deliver a chunk during the transition. + fn stop_locked(&self) { self.stopped.store(true, Ordering::Release); self.session.lock().revoke_external(); for job in self.queue.lock().drain(..) { @@ -121,6 +125,18 @@ impl Automation { }; for b in stopped { b.stop(); } } + /// Fence in-flight external writes, commit the session change, then drain. + /// A failed precondition leaves writer handles and queued payloads untouched. + pub(crate) fn transition_session(&self, id: &str, commit: impl FnOnce() -> Result) -> Result { + let mut bindings: Vec<_> = self.bindings.lock().values().filter(|b| b.session_id == id).cloned().collect(); + bindings.sort_by(|a,b| a.handle.cmp(&b.handle)); + let _operations: Vec<_> = bindings.iter().map(|b| b.operation.lock()).collect(); + let result = commit()?; + for binding in &bindings { binding.stop_locked(); } + let mut current = self.bindings.lock(); + for binding in &bindings { current.remove(&binding.handle); } + Ok(result) + } fn stop_binding(&self, binding: &Binding) { self.bindings.lock().remove(&binding.handle); binding.stop(); diff --git a/crates/frontend/src/automation/tests.rs b/crates/frontend/src/automation/tests.rs index 533f6ae..6681d5a 100644 --- a/crates/frontend/src/automation/tests.rs +++ b/crates/frontend/src/automation/tests.rs @@ -413,3 +413,53 @@ fn real_ssh_login_injected_by_a_launcher_stays_secret_after_sharing() { assert!(agent.call("snapshot",json!({"session":id})).is_err()); h.shutdown(); } + +#[test] +fn failed_sharing_preserves_external_handle_and_accepts_later_input() { + let (_tmp,h)=harness(); ready(&h); enable(&h); + let handle=open(&h); let id=physical(&h,&handle); + let session=h.state.hub.get(&id).unwrap(); + // Incomplete external input triggers the existing preflight guard. + session.lock().write_external(b"echo fixture").unwrap(); + let before=session.lock().status(); + let error=h.invoke("set_sharing",json!({"session":id,"shared":true,"connectionIds":[]})).unwrap_err(); + assert_eq!(error,"input_pending"); + assert!(h.state.automation.bindings.lock().contains_key(&handle)); + let after=session.lock().status(); + assert!(!after.shared && after.external_input_available); + assert_eq!(after.surface_generation,before.surface_generation); + let write=h.automate(caller(),"session.write",json!({"session":handle,"text":" continued","newline":true})).unwrap(); + assert_eq!(done(&h,write.as_str().unwrap())["state"],"delivered"); + h.shutdown(); +} + +#[test] +fn history_preparation_failure_does_not_revoke_external_input() { + let (_tmp,h)=harness(); ready(&h); enable(&h); let handle=open(&h);let id=physical(&h,&handle); + // Force only the new sharing resource open to fail. + let audit=h.state.config_dir.join("audit.jsonl"); + if audit.exists() { std::fs::remove_file(&audit).unwrap(); } + std::fs::create_dir(&audit).unwrap(); + assert_eq!(h.invoke("set_sharing",json!({"session":id,"shared":true,"connectionIds":[]})).unwrap_err(),"history_unavailable"); + assert!(h.state.hub.get(&id).unwrap().lock().external_writer_active()); + assert!(h.state.automation.bindings.lock().contains_key(&handle));h.shutdown(); +} + +#[test] +fn stale_sharing_selection_cannot_overwrite_a_newer_owner_decision() { + let (_tmp,h)=harness(); ready(&h); enable(&h); + let handle=open(&h); let id=physical(&h,&handle); + let snapshot=h.invoke("sharing_state",json!({"session":id})).unwrap(); + let revision=snapshot["revision"].as_u64().unwrap(); + let apply=|shared,expected| h.invoke("set_sharing",json!({"session":id,"shared":shared,"connectionIds":[],"expectedRevision":expected})); + assert_eq!(apply(true,revision+1).unwrap_err(),"sharing_changed"); + assert!(h.state.hub.get(&id).unwrap().lock().external_writer_active()); + assert!(h.state.automation.bindings.lock().contains_key(&handle)); + assert_eq!(apply(true,revision).unwrap()["shared"],true); + assert_eq!(apply(false,revision).unwrap_err(),"sharing_changed"); + assert!(h.state.hub.get(&id).unwrap().lock().status().shared); + let current=h.invoke("sharing_state",json!({"session":id})).unwrap(); + assert_eq!(current["revision"].as_u64(),Some(revision+1)); + assert_eq!(apply(false,revision+1).unwrap()["shared"],false); + h.shutdown(); +} diff --git a/crates/frontend/src/lib.rs b/crates/frontend/src/lib.rs index 0e89451..3930d2a 100644 --- a/crates/frontend/src/lib.rs +++ b/crates/frontend/src/lib.rs @@ -516,7 +516,7 @@ fn set_control_gate(state: &AppState, session: String, ask: bool) -> Result<(), } fn decide_control(state: &AppState, session: String, request_id: String, grant: bool) -> Result { - engine(&state, &session)?.session().lock().decide_control(&request_id, grant).map(|r| serde_json::to_value(r).unwrap()).map_err(|e| e.to_string()) + state.hub.decide_control(&session, &request_id, grant).map(|r| serde_json::to_value(r).unwrap()).map_err(|e| e.to_string()) } fn accept_proposal(state: &AppState, session: String, proposal_id: String) -> Result { @@ -528,7 +528,7 @@ fn reject_proposal(state: &AppState, session: String, proposal_id: String) -> Re } fn hand_back(state: &AppState, session: String) -> Result { - engine(&state, &session)?.session().lock().hand_back().map(|r| serde_json::to_value(r).unwrap()).map_err(|e| e.to_string()) + state.hub.hand_back(&session).map(|r| serde_json::to_value(r).unwrap()).map_err(|e| e.to_string()) } fn revoke_session_allow(state: &AppState, session: String, label: String) -> Result { @@ -612,6 +612,7 @@ fn dispatch(app: &AppHandle, state: &AppState, name: &str, args: Value) -> Resul Ok(Value::Null) }, "open_release" => updates::open(&arg::(&args, "url")?).map(|_| Value::Null), + "admission_snapshot" => Ok(json!(state.hub.admission_snapshot())), "pending_admissions" => Ok(json!(state.hub.pending_admissions().into_iter().map(|a| json!({"connId":a.conn_id,"agentId":a.agent_id})).collect::>())), "admission_policy" => Ok(json!({"ask": state.hub.admission_policy() == AdmissionPolicy::Ask})), "set_admission" => set_admission(state, arg::(&args, "ask")?), diff --git a/crates/frontend/src/surface_commands.rs b/crates/frontend/src/surface_commands.rs index 1d7fa8b..1e2101d 100644 --- a/crates/frontend/src/surface_commands.rs +++ b/crates/frontend/src/surface_commands.rs @@ -2,35 +2,89 @@ use crate::{arg, engine, AppState}; use serde_json::{json, Value}; -pub(crate) fn dispatch(state: &AppState, name: &str, args: &Value) -> Option> { - if !matches!(name, "set_sharing" | "sharing_participants") { return None; } +pub(crate) fn dispatch( + state: &AppState, + name: &str, + args: &Value, +) -> Option> { + if !matches!( + name, + "set_sharing" | "sharing_participants" | "sharing_state" + ) { + return None; + } Some((|| { let id: String = arg(args, "session")?; - if name == "sharing_participants" { + if matches!(name, "sharing_participants" | "sharing_state") { let e = engine(state, &id)?; let session = e.session(); let candidates = state.hub.agent_connections(); let s = session.lock(); - return Ok(json!(candidates.into_iter().map(|a| json!({"connId":a.conn_id,"agentId":a.agent_id,"idleSecs":a.idle_secs,"selected":s.participant_allowed(a.conn_id)})).collect::>())); + let participants = candidates.into_iter().map(|a| json!({"connId":a.conn_id,"agentId":a.agent_id,"idleSecs":a.idle_secs,"selected":s.participant_allowed(a.conn_id)})).collect::>(); + return Ok(if name == "sharing_state" { + json!({"revision":s.participation_generation(),"participants":participants}) + } else { + json!(participants) + }); } let e = engine(state, &id)?; let shared: bool = arg(args, "shared")?; - let selected: Vec = if shared { arg(args, "connectionIds")? } else { Vec::new() }; + let selected: Vec = if shared { + arg(args, "connectionIds")? + } else { + Vec::new() + }; + let expected: Option = if args + .get("expectedRevision") + .is_some_and(|value| !value.is_null()) + { + Some(arg(args, "expectedRevision")?) + } else { + None + }; let candidates = state.hub.agent_connections(); - if selected.iter().any(|id| !candidates.iter().any(|a| a.conn_id == *id)) { - return Err("A selected agent disconnected; choose participants again".into()); + if selected + .iter() + .any(|id| !candidates.iter().any(|a| a.conn_id == *id)) + { + return Err("connection_gone".into()); } - // Stop the owner-bound writer and drain its queue BEFORE acquiring Session. - // Its worker takes operation -> Session locks in the same order. - state.automation.stop_session(&id); + // Prepare fallible resources before changing authority. A disabled audit + // can be prepared here without recording private input or starting replay. let session = e.session(); - let mut s = session.lock(); - if shared && s.external_origin() && s.is_private() { - let audit = conn_core::audit::Audit::open(&state.config_dir.join("audit.jsonl")) - .map_err(|_| "Could not open collaboration history")?.for_session(id); - s.activate_shared_audit(audit); - } - s.set_shared_with_agents(shared, selected).map_err(|e| e.to_string())?; - serde_json::to_value(s.status()).map_err(|e| e.to_string()) + let needs_audit = { + let s = session.lock(); + shared && s.external_origin() && s.is_private() + }; + let audit = if needs_audit { + Some( + conn_core::audit::Audit::open(&state.config_dir.join("audit.jsonl")) + .map_err(|_| "history_unavailable")? + .for_session(id.clone()), + ) + } else { + None + }; + state.automation.transition_session(&id, || { + let mut s = session.lock(); + if expected.is_some_and(|revision| revision != s.participation_generation()) { + return Err("sharing_changed".into()); + } + let live = state.hub.agent_connections(); + if selected + .iter() + .any(|id| !live.iter().any(|a| a.conn_id == *id)) + { + return Err("connection_gone".into()); + } + s.validate_sharing(shared, &selected) + .map_err(|e| conn_core::ipc::RpcError::from(e).code)?; + if let Some(audit) = audit { + s.activate_shared_audit(audit); + } + s.set_shared_with_agents(shared, selected) + .map_err(|e| conn_core::ipc::RpcError::from(e).code)?; + Ok(json!(s.status())) + }) })()) } diff --git a/docs/PRD.ko.md b/docs/PRD.ko.md index 9a001a1..d8f15f5 100644 --- a/docs/PRD.ko.md +++ b/docs/PRD.ko.md @@ -52,3 +52,10 @@ Tauri 앱과 브라우저 테스트는 같은 하네스·UI를 사용합니다. 실제 렌더링, 가림, 숨김/별표 입력, 공유 경쟁 상태, 연결 사칭, 인증 후 같은 SSH 연결에서 협업을 검증합니다. Linux 자동 테스트와 macOS·Windows 설치 앱 검증은 구분합니다. + +### 협업 수명 규칙 명확화 (미배포) + +공유 세션이 없어도 앱 연결 승인을 처리할 수 있어야 합니다. 공유 사전 검증 실패는 기존 외부 +작성자를 유지하며, 오래된 참여자 선택은 다시 확인합니다. 명시적 회수·반환과 프로세스 종료는 +해당 대기 작업을 끝내므로 나중의 승인이 실행을 되살리지 않습니다. +[전환 표와 검증 범위](collaboration-refactor-results.ko.md)를 따릅니다. diff --git a/docs/PRD.md b/docs/PRD.md index ed79a86..11d518e 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -102,3 +102,10 @@ external authentication followed by same-session sharing, and human correction. Linux tests do not establish native macOS or Windows acceptance. Release notes must distinguish automated checks, browser runs and installed-app validation. + +### Collaboration lifecycle clarification (unreleased) + +App admission remains answerable without a shared session. A failed sharing preflight leaves the +external writer intact; stale participant selections require review again. Explicit takeover/release +and process exit end the corresponding pending work so a later decision cannot execute it. +See the [transition table and verified scope](collaboration-refactor-results.md). diff --git a/docs/architecture.ko.md b/docs/architecture.ko.md index bb797cf..03a779a 100644 --- a/docs/architecture.ko.md +++ b/docs/architecture.ko.md @@ -32,6 +32,17 @@ flowchart LR - 공개 IPC는 에이전트용 끝점입니다. MCP는 권한을 결정하지 않고, 코어가 요청마다 확인합니다. 네이티브 소유자 경로는 IPC로 접근할 수 없습니다. +## 협업 서비스 분리 (미배포) + +`ConnectionRegistry`는 앱 연결 승인과 살아 있는 연결 ID를, `Hub`는 여러 세션의 제어권을 +조율합니다. `Session`은 기존 잠금 안에서 참여 상태와 대기 작업 취소를 관리합니다. 프로토콜 +해석과 에이전트 작업은 별도 모듈입니다. 소유자 공유 서비스는 외부 입력 전달을 막아 둔 뒤 +입력·선택 revision·기록 준비가 성공해야 전환을 확정합니다. + +UI의 앱 연결 계약과 세션 이벤트 계약은 분리됩니다. 공통 상태 갱신기가 이벤트와 스냅샷을 +적용하고, 카드·키보드·팔레트는 명시적 세션·요청 ID를 가진 공통 결정 동작을 사용합니다. +[책임 배치·잠금 순서·검증](collaboration-refactor-results.ko.md)을 참고하세요. + ## 화면의 식별과 최신성 그리드 revision은 `surfaceId`, `generation`, `revision`, `outputSeq`, 크기, 보이는 텍스트, diff --git a/docs/architecture.md b/docs/architecture.md index 0081cd8..00e6490 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,6 +29,17 @@ flowchart LR - Public IPC is an agent endpoint. MCP does not decide permission; core checks it for every request. The native owner bridge is not available through IPC. +## Collaboration services (unreleased) + +`ConnectionRegistry` owns app admission and live IDs; `Hub` coordinates authority across sessions. +`Session` owns participation and pending-work cleanup under its existing lock. Protocol parsing and +agent operations are separate modules. The owner sharing service fences external delivery and commits +only after input, selection revision and history preflight succeed. + +The UI has separate app-admission and session-event contracts. Shared reducers reconcile snapshots +and events; decision actions supply explicit session/request IDs to reusable cards, keyboard and +palette commands. See [ownership, lock order and validation](collaboration-refactor-results.md). + ## Frame identity and freshness A grid revision identifies `surfaceId`, `generation`, `revision`, `outputSeq`, diff --git a/docs/collaboration-refactor-plan.ko.md b/docs/collaboration-refactor-plan.ko.md new file mode 100644 index 0000000..c9fe048 --- /dev/null +++ b/docs/collaboration-refactor-plan.ko.md @@ -0,0 +1,320 @@ +# 협업 경계 리팩터링 — 설계와 스프린트 + +[English](collaboration-refactor-plan.md) + +상태: **S0–S3 구현 / S4 로컬 검증 완료, 네이티브 인수 검증 대기** · 2026-09-21. +실제 완료 범위와 증거는 [구현 결과](collaboration-refactor-results.ko.md)를 따른다. +이 문서는 작업 범위와 완료 조건을 정한다. 구현 완료나 배포 승인을 의미하지 않는다. +현재 동작의 기준은 [제품 계약](PRD.ko.md), [아키텍처](architecture.ko.md), +[제어권 모델](control-lease-model.ko.md)이다. 아래 목표 타입과 모듈명은 제안이다. + +## 1. 목표와 범위 + +사용자가 외부 자동화로 연 터미널을 그대로 유지하면서 다음 흐름을 끝낼 수 있어야 한다. + +> 비공개 터미널 → 에이전트 연결 허용 → 참여자 선택 → 공유 시작 → 제어권 요청 → +> 사람의 승인과 개입 → 공유 중단 + +현재의 공통 `Harness`, 네이티브/브라우저 전송 어댑터, `Authority`, 터미널 화면 모델은 +유지한다. 책임을 분리할 대상은 **연결 승인, 세션 참여, 입력 제어, 상태 전환, UI 상태**다. +파일 분리 자체나 코드 줄 수 감소를 완료 기준으로 삼지 않는다. + +### 유지할 계약 + +- 공유를 바꿔도 세션 ID, PTY, SSH 연결, 자식 프로세스는 유지한다. +- 앱 연결 허용은 비공개 터미널 접근 허가나 제어권 부여가 아니다. +- 일반 탭의 기존 기본 참여 정책과 외부 생성 세션의 비공개 시작을 유지한다. +- 참여 선택은 살아 있는 연결 ID에 속한다. 이름으로 복원하거나 재접속에 상속하지 않는다. +- 공유 세션의 관찰·입력 권한을 창 포커스, 최소화, 활성 탭에 다시 묶지 않는다. +- 사람의 입력은 충돌하는 쓰기 권한보다 우선한다. 종료·공유 중단 이후 지연 입력은 실행하지 않는다. +- 원시 사람 입력과 외부 주입 값은 기록하지 않는다. 비공개 구간을 나중에 복원하지 않는다. +- 숨김 입력은 노출하지 않고, 별표 마스킹은 표시된 별표만 관찰한다. 화면에 출력된 값은 참여자에게 보인다. +- AppleScript 명령 이름·이벤트 코드·반환 형식, D-Bus 계약, 기존 설정을 유지한다. +- 브라우저 테스트는 같은 UI와 Harness를 사용한다. OS 어댑터를 브라우저 결과로 검증했다고 주장하지 않는다. + +### 이번 범위에서 제외 + +- 새 플러그인 실행 환경, 내부 모델 제공자/자동완성 복원, 새로운 shell backend. +- TUI의 미완성 입력 판정 알고리즘 교체. 기존 `input_pending`을 없애거나 우회하지 않는다. + 대신 이유를 보존하고 복구 방법을 설명한다. 입력 판정 개선은 별도 작업으로 재현·설계한다. +- Intel Mac 재지원, 설치 방식 변경, 마케팅·영상 개편, 모든 기존 UI 경고 일괄 수정. +- 사용자 연결 승인 정책을 끄거나 비공개 탭을 자동 공유하는 임시 해결책. + +## 2. 검토 결과와 작업 연결 + +| ID | 확인한 문제 | 근거 위치 | 해결 스프린트 | +| --- | --- | --- | --- | +| R1 | 앱 연결 승인 UI가 현재 탭의 `shared` 조건 아래 있어 비공개 창에서 숨겨짐 | `App.svelte`, `AdmissionRequest.svelte` | S0 | +| R2 | 앱 전체 연결 설정이 비공개 탭에서 숨겨지는 Agents 카테고리에 포함됨 | `SettingsSheet.svelte` | S0, S3 | +| R3 | 연결 승인 호출 실패 시 카드 제거, 공유 실패 메시지를 목록 갱신이 삭제 | `AdmissionRequest.svelte`, `SharingDialog.svelte` | S0, S3 | +| R4 | 공유 가능 여부를 최종 검사하기 전에 외부 작성자를 영구 중단 | `surface_commands.rs`, `Session::set_shared_with_agents` | S2 | +| R5 | 한 연결의 다중 세션 lease 정리가 IPC 요청 처리에 의존 | `ipc.rs::leave_other_tabs` | S1, S2 | +| R6 | 회수 사유별 승인·제안·Grace 정리와 이벤트 발행이 여러 메서드에 분산 | `session.rs` | S1, S2 | +| R7 | UI가 초기 조회·이벤트·폴링에서 같은 상태를 따로 갱신하며 이벤트 타입은 `any` 중심 | `App.svelte`, `bridge.ts`, `store.svelte.ts` | S1, S3 | +| R8 | 승인 테스트가 백엔드 이벤트와 명령 성공을 검증하나 비공개 화면의 실제 버튼 표시까지 보장하지 않음 | `admission_harness.rs`, 프런트엔드 테스트 구성 | S0, S4 | + +R1~R4는 코드에서 확인한 실패 경로다. R5~R8은 책임 배치·검증 범위의 결함이며, +각각을 모두 새로운 실사용 장애로 재현했다는 뜻은 아니다. + +## 3. 책임과 의존 방향 + +```mermaid +flowchart TD + UI[공통 Svelte UI] --> OWNER[소유자 명령 서비스] + OS[AppleScript / D-Bus] --> EXTERNAL[외부 자동화 서비스] + MCP[MCP / CLI] --> AGENT[에이전트 명령 서비스] + OWNER --> COORD[협업 전환 조율] + EXTERNAL --> COORD + AGENT --> COORD + COORD --> CONNECTION[연결 승인과 수명] + COORD --> SESSION[세션 집합체] + SESSION --> PARTICIPATION[참여 정책] + SESSION --> AUTHORITY[입력 제어권] + SESSION --> REVIEW[명령 검토와 대기 작업] + SESSION --> TERMINAL[PTY / 현재 화면] + CONNECTION --> EVENTS[범위가 명시된 상태와 이벤트] + SESSION --> EVENTS + EVENTS --> VIEW[UI 상태 갱신기] + VIEW --> UI +``` + +서비스들이 같은 실행 기반을 사용하더라도 호출자의 권한 경로는 구분한다. +외부 자동화는 소유자 명령을 임의로 실행하지 못하고, 에이전트는 자기 연결을 승인하지 못한다. + +| 책임 | 소유할 상태와 동작 | 소유하지 않을 것 | +| --- | --- | --- | +| `ConnectionRegistry` | 연결 ID, 승인 대기/허용/거절/종료, 승인 정책 | 탭 공개 여부, PTY 쓰기 | +| `Participation` | 비공개 또는 공유 대상, 참여 세대, 참여 검사 | 앱 연결 승인, lease | +| `Authority` | 사람/에이전트 lease, TTL, 회수 | UI 표시, 외부 자동화 큐 | +| `PendingWork` | 제어 요청, 명령 승인, 제안, Grace의 각각의 수명 | 네 가지 행위를 하나의 승인으로 취급하기 | +| `CollaborationCoordinator` | 여러 세션의 lease 조율, 전환 전제조건, 회수·취소 순서 | 요청 문자열 파싱, 카드 렌더링 | +| 외부 자동화 서비스 | OS 호출자 소유권, 외부 작성자 핸들·입력 큐 | 에이전트 참여·승인을 우회하기 | +| UI 상태 계층 | 초기 상태와 이벤트의 일관된 적용, 요청 진행·실패 상태 | 권한을 최종 허가하기 | +| 표시 컴포넌트 | 정보·버튼·포커스·접근성·모션 | 활성 탭을 추측해 명령 대상 정하기 | + +### 기존 crate 안에서 시작 + +- `conn-core`: 연결 레지스트리와 여러 세션 제어 서비스를 IPC 디코딩에서 분리한다. + `Session` 내부의 participation, pending-work 로직은 하위 모듈로 나누되 일관성 경계는 유지한다. +- `conn-frontend`: 소유 창 검사, 외부 작성자 중단, 기록 자원 준비를 포함한 공유 작업을 조율한다. + OS 어댑터는 신원 확인과 요청/응답 변환을 담당한다. +- 프런트엔드: typed contract, 앱 연결 저장소, 세션 저장소, 결정 요청 동작, + 공통 카드·dock 표시로 나눈다. +- 모듈마다 별도 crate, mutex, 백그라운드 작업을 만들지 않는다. 추출한 로직의 기존 복사본은 제거한다. + +## 4. 상태 모델과 API + +독립적인 축을 `privateMode` 같은 하나의 플래그에 다시 합치지 않는다. + +```text +ConnectionAdmission = Pending | Granted | Denied | Closed +SessionOrigin = Local | External (불변) +Participation = Private | Shared(AllAdmitted | Selected(connectionIds)) +AgentController = Human | Agent(lease) +ExternalWriter = Active(ownerHandle) | Revoked +``` + +외부 작성자 권한과 에이전트 lease는 서로 다른 수명·정책을 가진다. 하나의 lease 타입으로 +강제로 통합하지 않고, PTY 입력 경계에서 동시에 활성화되지 않도록 전환 서비스가 보장한다. +표시용 연결 이름은 식별자가 아니다. + +### 명시적 명령 대상 + +- 앱 명령: `decideAdmission(connectionId, decision)`. +- 세션 명령: `startSharing(sessionId, selection, expectedRevision)`. +- 제어 명령: `requestControl(connectionId, sessionId, reason)`. +- 결정 명령: `resolveCommandApproval(sessionId, requestId, decision)`. + +실제 타입명은 구현하면서 정하되, 협업 관련 새 API는 `st.active`를 암묵적으로 덧붙이지 않는다. +공개 MCP 메서드와 AppleScript 형태는 어댑터에서 호환 유지한다. 내부 타입 정리가 공개 계약 +변경을 요구하면 같은 리팩터링에 숨기지 않고 별도 호환성 항목으로 기록한다. + +### 상태·이벤트·오류 + +- UI 이벤트는 `AppEvent`, `WindowEvent`, `SessionEvent`로 범위를 명시한다. + 공개 에이전트 이벤트는 기존 참여 검사와 전송 직전 세대 검사를 유지한다. +- 협업 상태에는 revision을 부여하고 동일한 갱신기가 snapshot과 event를 적용한다. + 구독 후 초기 조회 사이의 경쟁, 중복·과거 이벤트, 재연결을 테스트한다. +- 작업 오류는 `input_pending`, `connection_gone`, `request_already_resolved`, + `history_unavailable` 등의 타입과 가능한 다음 행동으로 전달한다. +- 에이전트 목록 조회 오류와 공유 적용 오류는 별도 상태다. 목록 갱신으로 작업 오류를 지우지 않는다. +- 통신 결과가 불확실하면 권위 있는 상태를 재조회한다. 승인·주입·실행을 자동 재시도하지 않는다. +- 진단은 단계, 정적 오류 코드, 비밀값이 아닌 식별자로 제한한다. 입력·명령 인자·자격 증명을 + 오류 개선의 명목으로 새로 저장하지 않는다. + +## 5. 전환 설계 + +### 비공개 → 공유 + +1. 실제 소유 창, 살아 있는 세션, 선택한 승인 연결, 입력 상태를 검사한다. +2. 필요한 기록 자원을 준비한다. 여기서 실패하면 기존 권한을 바꾸지 않는다. +3. 해당 외부 작성자 작업과 세션 상태를 순서대로 보호하는 짧은 전환 구간에 들어간다. + 기존 `operation → Session` 잠금 순서를 보존하며 네트워크·파일 I/O·사용자 응답을 기다리지 않는다. +4. 입력 상태, 세션/참여 revision, 선택 연결의 수명을 다시 검사한다. + 검증 실패는 전환 전 상태를 유지한다. 그동안 발생한 사람의 선점·연결 종료는 되돌리지 않는다. +5. 하나의 전환으로 외부 작성자를 폐기하고 큐의 배달을 막고 참여자를 적용한다. + 제어권은 사람에게 두고 세대를 갱신한다. 어느 시점에도 두 작성자가 활성화되지 않는다. +6. 완료 상태와 이벤트를 발행한다. 이후 UI 전달이 실패하면 재조회로 복구하며 외부 작성자를 복원하지 않는다. + +외부 큐 항목이 이미 꺼내진 경우도 전환 경계에서 배달 가능 여부를 다시 검사해야 한다. +연결이 동시에 종료되면 새 소켓에 선택이 상속되지 않아야 한다. +여러 잠금을 필요로 하는 구체적 순서와 콜백 규칙은 S2 구현 전 설계 검토 항목이다. + +### 회수·취소 규칙 + +| 사건 | 필요한 결과 | +| --- | --- | +| 사람 입력/명시적 제어 회수 | 충돌하는 작성자와 실행 대기를 무효화. 사람 입력 전에 처리하며 사람의 입력 내용은 저장하지 않음 | +| 공유 중단/참여자 제거 | 영향받는 연결의 권한·승인·제안·Grace·제어 요청 취소. PTY 유지 | +| 에이전트의 탭 이동/다른 탭 제어 요청 | 한 연결이 다른 세션에 남긴 lease와 대기 작업을 공통 서비스에서 정리 | +| 연결 종료 | 그 연결의 모든 세션 작업 정리. 같은 이름의 다른 연결은 독립 | +| lease 만료 | 만료된 권한으로 지연 실행 불가. 기존 승인 대기 중 갱신 정책은 명시적으로 유지 | +| 프로세스/창 종료 | 해당 세션/소유 창 범위 정리. 다른 창의 세션은 유지 | +| 창 포커스/가림/최소화 변경 | 참여와 lease 변화 없음 | + +S1에서 기존 동작을 테스트로 고정하고 각 사건의 승인·제안·제어 요청 처리 차이를 표로 만든다. +수동 회수 뒤 승인 생존 등 애매한 동작은 제품 불변식에 비춰 결정하고 변경 이력을 남긴다. +취소 헬퍼를 통합한다는 이유만으로 서로 다른 사건을 같은 처리로 바꾸지 않는다. +셸/TUI의 입력을 지우는 키도 임의로 추가하지 않는다. + +여러 세션의 lease 규칙은 IPC 요청뿐 아니라 소유자의 제어 허용·돌려주기 경로에도 적용한다. +짧은 권한 변경만 직렬화하고, 사람 입력이 다른 세션의 승인 대기 때문에 막히지 않도록 한다. + +## 6. UI 구성과 공통화 + +```text +AppShell +├─ ApplicationRequests 연결 승인: 비공개/공유/빈 창과 독립 +├─ SessionWorkspace +│ ├─ Terminal +│ └─ SessionInteractions 제어 요청·명령 검토·제안·Grace +├─ SharingDialog 참여자 선택, 전환 결과 +└─ Settings + ├─ AppConnections 에이전트 설치·연결 승인 정책 + └─ SessionPolicy 모드·명령 검토·속도 +``` + +두 요청 영역은 공통 dock 레이아웃과 공간 확보 모션을 쓴다. `private` 조건은 앱 요청 영역을 +숨길 수 없다. S0에서는 기존 모션을 유지하고 S3에서 레이아웃을 공통화한다. + +공통 `DecisionCard`는 heading, 설명, 상세, action slot, 처리 중·오류 표시, 포커스를 담당한다. +연결 승인, 제어권 부여, 명령 승인은 각각의 view model과 handler를 유지한다. +카드마다 CSS와 통신 처리 코드를 복사하지 않고, 하나의 거대한 permission component도 만들지 않는다. + +- 실패하면 카드와 원인을 유지한다. 이미 해결된 요청만 제거한다. +- 다른 창에서 처리하면 모든 창의 같은 요청이 정리된다. +- 선택은 연결 ID로 유지하고 재접속한 에이전트는 다시 선택한다. +- 연결 후보가 없으면 연결 대기/승인 필요/연결 없음의 차이를 표시한다. +- 적용 완료는 최신 백엔드 상태로 반영한다. 색·토스트만 바꾸어 성공을 표현하지 않는다. +- 영어/한국어, 키보드, 좁은 창, reduced motion을 같은 컴포넌트에서 검증한다. + +## 7. 스프린트 계획 + +기간은 1인 구현·검토 기준 **순수 작업량 추정**이다. 플랫폼 검증 대기와 CI 대기는 별도이며, +자동 실행 일정이나 배포 약속이 아니다. 전체 예상은 7~9 작업일, S2의 동시성 검토가 가장 큰 변수다. + +| 스프린트 | 목표 | 추정 | 의존 | 독립 산출물 | +| --- | --- | --- | --- | --- | +| S0 | 막힌 승인·공유 UI 복구 | 1일 | 없음 | 긴급 수정 가능한 작은 변경 | +| S1 | 권한 범위·전환·타입 계약 고정 | 1일 | S0 | 설계 결정, 회귀 기준, 내부 타입 | +| S2 | 전환 서비스와 lease 수명 공통화 | 2~3일 | S1 | 공통 백엔드, 경쟁·실패 테스트 | +| S3 | UI 상태와 결정 컴포넌트 공통화 | 2일 | S1, S2 계약 | 공통 UI, 타입 검사, 상호작용 테스트 | +| S4 | 플랫폼·설치 배포판 검증 | 1~2일 | S0~S3 | 후보 검증 보고서, 릴리즈 준비 | + +### S0 — 사용자가 막힌 흐름 복구 + +- S0-1: 연결 승인 표시를 공유 조건 밖으로 이동. 일반/비공개/세션 준비 중 창에서 유지. +- S0-2: 전역 연결 설정을 private 조건과 분리. 세션 정책 제한은 유지. +- S0-3: 실패한 승인 카드를 유지하고 재조회 경로 제공. 공유 작업 오류와 목록 오류 분리. +- S0-4: **현재 코드에서 실패하는** 비공개 화면 승인 테스트를 먼저 만들고 수정 후 통과 확인. + +완료 조건: 새 연결 대기 → 실제 허용 클릭 → 비공개 화면은 여전히 접근 불가 → 참여자 선택 → +공유 → 스냅샷 가능. 통신 실패·다른 창에서 먼저 처리한 요청도 회복 가능. +이 단계에서 S2의 부분 전환 문제까지 해결했다고 주장하지 않는다. + +### S1 — 경계와 계약 고정 + +- S1-1: 연결, 세션, 창, 요청, lease 식별자와 범위가 있는 이벤트/오류 타입 정의. +- S1-2: 권한·회수 진입점 목록화. `request_control`, 제어 허용, `hand_back`, 이동, 종료 포함. +- S1-3: 전환 표 및 검증/취소 순서 작성. 현재 동작 보존 항목과 결함 수정 항목 분리. +- S1-4: 기존 테스트를 진입점과 불변식에 연결. AppleScript/D-Bus/public IPC 계약 fixture 보존. + +완료 조건: 연결 승인과 세션 참여가 타입과 API에서 구분되고, 모든 lease 부여/회수 경로의 +담당 서비스가 지정됨. 기능 변경이 필요한 모호한 항목을 문서에 숨겨두지 않음. + +### S2 — 백엔드 전환 공통화 + +- S2-1: 연결 관리와 여러 세션 lease 조율을 IPC 파싱에서 추출. +- S2-2: pending work 정리를 회수 사유별로 공통화하고 기존 중복 경로 제거. +- S2-3: 외부 작성자·공유·기록 준비를 조율하는 전환 구현. 실패 전제조건은 기존 권한 유지. +- S2-4: 프로세스 종료, 연결 종료, 사람 입력, 공유 변경과의 경쟁을 barrier/fault injection으로 검증. +- S2-5: OS 어댑터·Harness·IPC가 같은 규칙을 호출하는지 확인. 오류에 입력 값이 포함되지 않도록 검증. + +완료 조건: 한 연결의 여러 lease가 어떤 부여 경로에서도 생기지 않음. 실패한 공유 시작이 +작성자만 중단하지 않음. 공유 성공 후 이전 외부 핸들의 입력과 취소된 지연 ENTER가 실행되지 않음. +전환 중 사람의 입력을 장시간 막지 않으며 동일 세션·프로세스가 유지됨. + +### S3 — UI 상태와 컴포넌트 공통화 + +- S3-1: 앱 연결 상태와 세션 협업 상태 분리. 협업 영역의 `any` 이벤트와 암묵적 active-session 명령 제거. +- S3-2: snapshot/event/reconnect를 같은 상태 갱신기로 처리. 오래된 응답이 최신 상태를 되돌리지 못하게 함. +- S3-3: 공통 결정 동작과 카드/dock primitive 추출. 비즈니스 결정 종류는 유지. +- S3-4: 설정 범위 정리, 실패 이유·다음 행동·비활성 이유의 i18n 적용. +- S3-5: 여러 창, 빠른 탭 이동, 키보드, 작은 창, reduced motion 및 기존 공간 확보 모션 검증. + +완료 조건: 어느 창에서도 앱 연결 승인 요청이 세션 조건 때문에 사라지지 않음. 실패·재연결·중복 +이벤트에도 요청과 상태가 일관됨. 새 컴포넌트가 기존과 별도의 권한 판단을 갖지 않음. + +### S4 — 실제 설치 경로와 릴리즈 준비 + +- S4-1: 같은 Harness의 브라우저에서 아래 수용 시나리오를 자동 검증. +- S4-2: macOS Apple Silicon **서명된 후보 앱**을 설치 경로로 지정해 AppleScript 생성·인증·공유를 검증. + 앱 종료 상태와 실행 중 상태 모두 포함. 앱 이름 선택 문제는 정확한 bundle identity/path로 구분. +- S4-3: Linux의 네이티브 D-Bus 흐름, Windows의 네이티브 MCP 연결·공유 흐름 검증. + 없는 OS 어댑터를 새로 구현하는 스프린트는 아님. +- S4-4: 기존 설정에서 업그레이드, 앱/MCP 재시작, 공유 선택 비상속, 서명·설치·업데이트 점검. +- S4-5: PRD/아키텍처/프로토콜/사용법/변경 기록의 실제 변경분을 영문·한국어로 갱신. + +완료 조건: commit, 후보 버전, OS, 설치 방법, 시나리오 결과, 미검증 항목이 있는 보고서. +개발 앱 성공과 서명된 설치 앱 성공을 별도로 표기. 필수 플랫폼 검증 미완료는 완료로 처리하지 않음. + +## 8. 수용 테스트 + +| ID | 시나리오 | 반드시 확인할 결과 | +| --- | --- | --- | +| A1 | 외부 자동화 비공개 창에서 새 에이전트 접속 | 허용 카드가 보이고 클릭 가능. 연결 허용만으로 화면 공개 안 됨 | +| A2 | 허용된 연결을 선택하고 공유 시작 | 동일 세션·PTY·SSH 유지. 선택된 연결만 화면 관찰, 제어는 별도 요청 | +| A3 | 선택 없음/선택 연결 종료/미완성 입력/기록 준비 실패 | 구체적 이유 유지. 시작 전 검증 실패로 작성자만 중단하지 않음 | +| A4 | 공유 전환과 외부 큐 배달 경쟁 | 전환 후 주입 차단. 원시 주입 값은 기록·공개 응답에 없음 | +| A5 | 승인 클릭 통신 실패, 다른 창에서 먼저 승인, 과거 snapshot 도착 | 미해결 요청 유지·재조회. 중복 실행·최신 상태 되돌림 없음 | +| A6 | 사람 선점/명시적 회수/연결 종료/탭 이동 중 승인·제안·Grace 대기 | 사건별 취소 규칙 충족. 지연 ENTER와 다른 탭의 숨은 lease 없음 | +| A7 | 공유 중단 후 재공유, 같은 이름의 재접속 | 비공개 구간 기록 복원 없음. 새 연결 자동 선택 없음 | +| A8 | 포커스 잃음/최소화/다른 탭 보기 | 참여 권한과 lease 유지. 사람의 현재 탭을 에이전트가 자동 추종하지 않음 | +| A9 | 인증 입력 무표시/별표/화면에 보이는 합성 값 | 각 렌더링 계약대로 관찰. 숨김 값은 snapshot·기록·오류에서 검출되지 않음 | +| A10 | AppleScript 앱 이름/정확한 경로, cold/warm launch | 앱 선택 문제와 실행 오류 구분. 해당 후보 bundle의 사전·실행 모두 검증 | +| A11 | 영어/한국어·키보드·좁은 창·reduced motion | 승인·공유 흐름 완료, 카드가 터미널 입력을 가리지 않음 | + +합성 인증 데이터만 사용한다. 자동화 테스트가 외부 시스템에 실제 계정으로 접속하지 않게 한다. +조합 전체를 무작정 늘리기보다 각 경계 실패와 핵심 종단 흐름을 검증한다. + +## 9. 전달과 완료 관리 + +- S0를 먼저 독립 변경으로 만든다. 후속 구현은 검토 가능한 크기로 순차 통합한다. + 겹치는 PR을 여러 개 열어두지 않는다. +- 구현 완료, 자동 테스트, 브라우저 검증, 네이티브 후보 검증, 배포를 각각 기록한다. +- 순수 구조 변경은 행동을 보존하는 테스트를 먼저 두고, 의도적 행동 수정은 별도 변경으로 식별한다. +- 릴리즈 작업을 한다면 저장소의 기존 [릴리즈 절차](../scripts/release.py)를 따른다. + 이 계획만으로 push·merge·tag·공개 배포를 실행하지 않는다. +- 예상 밖의 회귀는 버그 수정 또는 해당 스프린트 변경의 되돌리기로 대응한다. + 승인 정책을 끄거나 비공개 경계를 완화해서 통과시키지 않는다. + +### 추적할 후속 항목 + +- TUI에서 `input_pending`이 실제 입력 상태와 어긋나는 사례의 별도 재현·설계. +- 자동화 실패를 비밀값 없이 단계별로 설명하는 native 진단 개선. +- 협업 범위를 벗어난 UI 경고와 기타 설정 UX 개선. + +## 10. 계획 수립 시 검증 범위 + +코드와 기존 문서·테스트를 검토하여 작성했다. 이번 문서 작성으로 구현, 새 테스트 실행, +네이티브 앱 재현 또는 배포가 수행된 것은 아니다. 각 스프린트의 완료 조건은 앞으로 검증할 항목이다. diff --git a/docs/collaboration-refactor-plan.md b/docs/collaboration-refactor-plan.md new file mode 100644 index 0000000..a0bacb8 --- /dev/null +++ b/docs/collaboration-refactor-plan.md @@ -0,0 +1,322 @@ +# Collaboration boundaries — design and sprint plan + +[한국어](collaboration-refactor-plan.ko.md) + +Status: **S0–S3 implemented / S4 local acceptance passed, native acceptance pending** · 2026-09-21. +See [implementation results](collaboration-refactor-results.md) for the completed scope and evidence. +This plan defines scope and acceptance; it does not establish completion or authorize publication. +The current baseline is the [product contract](PRD.md), [architecture](architecture.md), and +[control lease model](control-lease-model.ko.md). Target types and module names below are proposals. + +## 1. Goal and preserved behavior + +A person must complete this flow without replacing the shell opened by external automation: + +> Private terminal → admit agent connection → select participants → start sharing → request control +> → human review and takeover → stop sharing. + +Keep the shared `Harness`, native/browser transport adapters, `Authority`, and terminal screen model. +Separate connection admission, participation, input authority, transitions, and UI state. File count +or line-count reduction is not the acceptance criterion. + +- Sharing changes preserve session ID, PTY, SSH connection, and child process. +- Connection admission does not disclose a private session or grant control. +- Preserve ordinary tabs' default participation policy and private startup for external sessions. +- Selection belongs to live connection IDs; names and reconnects never inherit it. +- Focus, minimization, and the human's selected tab never gate shared-session access. +- Human input preempts conflicting writers. Revoked work cannot deliver a delayed ENTER. +- Never record raw human input or external injection payloads, or reconstruct private activity later. +- Hidden input stays absent; star masking exposes only rendered stars. Printed values are visible. +- Preserve AppleScript terminology, event codes and return shapes, D-Bus contracts, and saved settings. +- Browser acceptance uses the same UI and Harness; it does not prove native adapter behavior. + +Out of scope: a new executable plugin runtime; restoration of native model completion; new shell +backends; Intel Mac support; installer, marketing or film changes; unrelated UI warnings. The TUI +`input_pending` heuristic needs a separate investigation. This plan preserves its guard and improves +the explanation, rather than bypassing it. Never disable admission or auto-share private tabs as a fix. + +## 2. Findings mapped to work + +| ID | Finding | Evidence location | Sprint | +| --- | --- | --- | --- | +| R1 | App admission UI sits under the active session's `shared` condition | `App.svelte`, `AdmissionRequest.svelte` | S0 | +| R2 | Private sessions hide the Agents category, including app-wide connection settings | `SettingsSheet.svelte` | S0, S3 | +| R3 | Failed admission removes its card; participant refresh clears sharing mutation errors | `AdmissionRequest.svelte`, `SharingDialog.svelte` | S0, S3 | +| R4 | Sharing permanently stops external input before final transition validation | `surface_commands.rs`, `Session::set_shared_with_agents` | S2 | +| R5 | Cross-session lease exclusivity depends on IPC request handling | `ipc.rs::leave_other_tabs` | S1, S2 | +| R6 | Revocation, approval/proposal/grace cleanup and events are spread across methods | `session.rs` | S1, S2 | +| R7 | Initial reads, events and polling independently update UI state through loosely typed data | `App.svelte`, `bridge.ts`, `store.svelte.ts` | S1, S3 | +| R8 | Admission tests cover backend events and decisions, not actual controls in a private view | `admission_harness.rs`, frontend test configuration | S0, S4 | + +R1–R4 describe verified code paths. R5–R8 identify structural or coverage weaknesses, not a claim +that every one has been reproduced as a new runtime incident. + +## 3. Ownership and dependency direction + +```mermaid +flowchart TD + UI[Shared Svelte UI] --> OWNER[Owner command service] + OS[AppleScript / D-Bus] --> EXTERNAL[External automation service] + MCP[MCP / CLI] --> AGENT[Agent command service] + OWNER --> COORD[Collaboration coordinator] + EXTERNAL --> COORD + AGENT --> COORD + COORD --> CONNECTION[Connection admission and lifetime] + COORD --> SESSION[Session aggregate] + SESSION --> PARTICIPATION[Participation] + SESSION --> AUTHORITY[Input authority] + SESSION --> REVIEW[Review and pending work] + SESSION --> TERMINAL[PTY / current screen] + CONNECTION --> EVENTS[Scoped state and events] + SESSION --> EVENTS + EVENTS --> VIEW[UI state reducer] + VIEW --> UI +``` + +Shared services do not erase trust boundaries. External automation cannot invoke arbitrary owner +commands; agents cannot admit themselves. + +| Responsibility | Owns | Does not own | +| --- | --- | --- | +| `ConnectionRegistry` | Live IDs, pending/granted/denied/closed state, admission policy | Session audience, PTY input | +| `Participation` | Private/shared audience, participation generation, access checks | Connection admission, leases | +| `Authority` | Human/agent lease, TTL and revocation | UI rendering, external input queues | +| `PendingWork` | Separate lifecycles for control requests, command review, proposals and grace | Collapsing these human decisions into one approval | +| `CollaborationCoordinator` | Cross-session lease coordination, transition preconditions and cancellation order | Transport parsing, card rendering | +| External automation service | OS caller ownership, external writer handles and queues | Bypassing agent participation or approval | +| UI state layer | Consistent snapshot/event application, request progress and failure | Final permission decisions | +| Presentation components | Content, controls, focus, accessibility and motion | Guessing command targets from the active tab | + +### Work inside existing crates first + +- In `conn-core`, extract connection management and cross-session authority from IPC decoding. + Split participation/pending-work implementation within `Session`, retaining its consistency boundary. +- In `conn-frontend`, coordinate owner-window checks, external-writer fencing and audit resource + preparation. OS adapters identify callers and translate requests/results. +- In the frontend, separate typed contracts, app connections, session stores, decision actions and + shared card/dock presentation. +- Do not add one crate, mutex or background task per module. Remove superseded implementations. + +## 4. State and API contracts + +Keep these axes independent: + +```text +ConnectionAdmission = Pending | Granted | Denied | Closed +SessionOrigin = Local | External (immutable) +Participation = Private | Shared(AllAdmitted | Selected(connectionIds)) +AgentController = Human | Agent(lease) +ExternalWriter = Active(ownerHandle) | Revoked +``` + +External writers and agent leases have different lifetimes and policies. Retain separate authority +types and guarantee mutually exclusive input across their transition boundary. A display name is +never an identity. + +Examples of explicitly scoped internal commands: + +- `decideAdmission(connectionId, decision)` — app connection. +- `startSharing(sessionId, selection, expectedRevision)` — session participation. +- `requestControl(connectionId, sessionId, reason)` — input authority. +- `resolveCommandApproval(sessionId, requestId, decision)` — command review. + +New collaboration APIs must not implicitly append `st.active`. Existing public MCP methods and +AppleScript syntax remain compatible through adapters. Any unavoidable public contract change is +an explicit compatibility item, not an incidental refactor. + +### State, events and errors + +- Distinguish `AppEvent`, `WindowEvent`, and `SessionEvent`. Public agent delivery retains access + and generation checks immediately before sending. +- Use versioned collaboration state and one reducer for snapshots/events. Test subscribe/initial-read + races, duplicate and old events, and reconnect reconciliation. +- Return typed errors such as `input_pending`, `connection_gone`, `request_already_resolved`, and + `history_unavailable`, with appropriate next actions. +- Keep participant-query failure separate from sharing-mutation failure. Refresh cannot clear the latter. +- Reconcile uncertain results with authoritative state; never automatically replay approval, injection + or execution. +- Diagnostics use stages, static codes and non-secret identifiers. Do not add logging of input, + command arguments or credentials to improve error reporting. + +## 5. Transition design + +### Private to shared + +1. Validate the actual owner window, live session, admitted selection and pending input. +2. Prepare required audit resources. Failure here leaves existing authority unchanged. +3. Enter a short transition section that protects the external operation and session in order. + Preserve `operation → Session` ordering; do not wait for network, file I/O or a human decision there. +4. Revalidate input, session/participation revision and selected connection lifetimes. Failure preserves + pre-transition state. Never undo a concurrent human takeover or disconnection. +5. Commit external-writer revocation, prevent queued delivery, apply participation, retain human + control and advance generations. No interval may authorize both writers. +6. Publish committed state/events. A later UI delivery failure is repaired by state reconciliation, + not by restoring the external writer. + +Already-dequeued external input must recheck delivery authority at this boundary. A disappearing +connection must not transfer selection to its successor. The concrete multi-lock ordering and callback +rules require review before S2 implementation. + +### Revocation and cancellation + +| Event | Required outcome | +| --- | --- | +| Human input / explicit takeover | Invalidate conflicting writer and execution work before forwarding human input; do not retain its contents | +| Stop sharing / remove participant | Cancel affected authority, reviews, proposals, grace and control requests; preserve PTY | +| Agent moves / requests control elsewhere | Common service clears that connection's leases and pending work in other sessions | +| Connection closes | Clear its work across sessions; same-name connections remain independent | +| Lease expires | No delayed execution under expired authority; explicitly preserve the existing review-wait renewal policy | +| Process / window closes | Clean up that session or owning window, preserving other windows | +| Blur / cover / minimize | No participation or lease change | + +S1 records current behavior and differences in treatment of reviews, proposals and control requests. +Resolve ambiguous cases, including review survival after manual takeover, against the product +invariants and document intentional changes. A shared cancellation helper does not make every event +semantically identical. Do not introduce guessed shell/TUI clearing keystrokes. + +Cross-session exclusivity applies to owner grants and hand-back as well as IPC requests. Serialize +short authority mutations; human input must not wait behind another session's approval wait. + +## 6. UI structure and reuse + +```text +AppShell +├─ ApplicationRequests Admission, independent of private/shared/empty session +├─ SessionWorkspace +│ ├─ Terminal +│ └─ SessionInteractions Control requests, review, proposals, grace +├─ SharingDialog Selection and transition results +└─ Settings + ├─ AppConnections Agent setup and connection admission policy + └─ SessionPolicy Mode, command review and pacing +``` + +The request regions share dock layout and space-reserving motion. A private-session condition cannot +hide app requests. S0 preserves existing motion; S3 extracts the common layout. + +A `DecisionCard` primitive owns heading, description, details, action slots, progress, errors and focus. +Admission, control grants and command approval retain separate view models and handlers. Avoid both +copied CSS/network handlers and one oversized permission component. + +- Keep failures and their cause visible; remove only confirmed resolved requests. +- A decision in one window reconciles the same request in every other window. +- Keep selections by live connection ID; reconnects require fresh selection. +- Distinguish no connections, admission pending and no eligible participants. +- Reflect successful changes from backend state, not only a toast or a color. +- Test English/Korean, keyboard navigation, narrow windows and reduced motion on shared components. + +## 7. Sprints + +Estimates are **engineering effort for one implementer/reviewer**, not elapsed-time promises or a +scheduled automation. Platform availability and CI wait are additional. Total: approximately 7–9 +working days; concurrency review in S2 is the largest uncertainty. + +| Sprint | Goal | Estimate | Depends on | Deliverable | +| --- | --- | --- | --- | --- | +| S0 | Unblock admission and sharing UI | 1 day | None | Small independently deliverable fix | +| S1 | Fix scope, transition and type contracts | 1 day | S0 | Decisions, regression baseline, internal types | +| S2 | Centralize transitions and lease lifecycle | 2–3 days | S1 | Common backend with race/failure tests | +| S3 | Unify UI state and decision components | 2 days | S1 and S2 contracts | Shared UI, type checks, interaction tests | +| S4 | Verify native and installed candidates | 1–2 days | S0–S3 | Candidate report and release preparation | + +### S0 — restore the blocked path + +- S0-1: Render admission independently of sharing, including private and preparing windows. +- S0-2: Separate global connection settings from private-session restrictions. +- S0-3: Retain failed admission requests and reconcile them; separate mutation/query errors in sharing. +- S0-4: First add a private-view admission regression that fails on the baseline, then verify the fix. + +Done: pending connection → actual Allow click → private screen still inaccessible → select connection +→ share → snapshot succeeds. Recover from transport failure and another window resolving first. +Do not claim the partial transition in S2 is fixed by this UI patch. + +### S1 — fix boundaries and contracts + +- S1-1: Define scoped connection/session/window/request/lease IDs, events and error types. +- S1-2: Inventory every authority entry point, including request, owner grant, hand-back, move and close. +- S1-3: Define the transition/cancellation table and distinguish behavior preservation from bug fixes. +- S1-4: Map tests to entry points and invariants; retain AppleScript/D-Bus/public IPC contract fixtures. + +Done: admission and participation are distinct in types/APIs; every lease mutation has a named owner; +ambiguous behavior changes are recorded rather than hidden in extraction work. + +### S2 — centralize backend transitions + +- S2-1: Extract connection management and cross-session authority from IPC parsing. +- S2-2: Centralize reason-specific pending-work cancellation and remove duplicate paths. +- S2-3: Implement coordinated sharing, external-writer fencing and audit preparation. Failed + preconditions preserve prior authority. +- S2-4: Use barriers/fault injection for races with exit, disconnect, human input and sharing changes. +- S2-5: Verify common rules through OS adapters, Harness and IPC, with payload-free errors. + +Done: no grant path creates multiple held sessions for one connection; failed sharing does not only +stop the external writer; successful sharing rejects old external input and cancelled delayed ENTER. +Transitions preserve session/process and do not block human input on long waits. + +### S3 — unify UI state and components + +- S3-1: Separate app connections and session collaboration state; remove collaboration `any` events + and implicitly targeted active-session commands. +- S3-2: Apply snapshot/event/reconnect through one reducer; old responses cannot revert newer state. +- S3-3: Extract common decision behavior and card/dock primitives while preserving decision semantics. +- S3-4: Clarify settings scopes, failure reasons, recovery actions and disabled states in both languages. +- S3-5: Verify multiple windows, rapid tab changes, keyboard use, narrow layouts, reduced motion and + existing space-reserving motion. + +Done: session state cannot hide app admission; requests reconcile across failure/reconnect/duplicates; +components contain no separate authority policy. + +### S4 — installed candidate and release preparation + +- S4-1: Automate the acceptance flow in the browser using the production Harness. +- S4-2: Install the **signed macOS Apple Silicon candidate** and test AppleScript creation, + authentication and sharing for cold/warm launches. Target the exact bundle path/identity to isolate + app-name resolution from adapter failures. +- S4-3: Verify native Linux D-Bus and Windows MCP admission/sharing. Do not add an absent OS adapter. +- S4-4: Check upgrade from existing settings, app/MCP restart, selection non-inheritance, signatures, + installation and update behavior. +- S4-5: Update the actual changes in PRD, architecture, protocol, usage and changelog in English/Korean. + +Done: evidence records commit, candidate version, OS, installation route, outcomes and unverified +items. Development-app and signed-installed-app results are distinct. Missing required native +verification cannot be reported as complete. + +## 8. Acceptance scenarios + +| ID | Scenario | Required evidence | +| --- | --- | --- | +| A1 | New agent while an externally launched private window is active | Visible actionable admission; admitting alone discloses no private screen | +| A2 | Select admitted connection and share | Same session/PTY/SSH; only selected connection observes; control requested separately | +| A3 | Empty selection, disconnected selection, pending input or audit preparation failure | Persistent specific cause; failed preflight does not only revoke the writer | +| A4 | Share races with external queue delivery | No delivery after transition; no raw payload in history or public responses | +| A5 | Decision transport failure, another window decides first, stale snapshot arrives | Retain/reconcile unresolved request; no duplicate execution or state rollback | +| A6 | Takeover, disconnect or move during review/proposal/grace | Correct reason-specific cancellation; no delayed ENTER or hidden lease elsewhere | +| A7 | Stop/restart sharing and reconnect under the same name | No retrospective private history or inherited selection | +| A8 | Unfocused/minimized/other human tab | Participation and lease survive; agent never follows human tab selection | +| A9 | Hidden, star-masked and visibly printed synthetic authentication data | Projection follows rendering contract; hidden values absent from snapshots/history/errors | +| A10 | AppleScript app name versus exact path, cold/warm launch | Distinguish selection from execution failure; validate candidate dictionary and execution | +| A11 | English/Korean, keyboard, narrow window, reduced motion | Complete the flow with readable controls and unobscured terminal input | + +Use only synthetic authentication data; automated tests must not authenticate to external systems +with real accounts. Cover boundary failures and meaningful end-to-end flows rather than expanding +every possible combination indiscriminately. + +## 9. Delivery and completion tracking + +- Make S0 independent. Integrate subsequent reviewable changes sequentially; avoid overlapping PRs. +- Track implementation, automated tests, browser verification, native candidate verification and + deployment separately. +- Characterize behavior before structural changes; identify intentional behavior fixes separately. +- Release work, if requested, follows the existing [release tooling](../scripts/release.py). + This plan alone does not initiate push, merge, tags or publication. +- Address regressions with fixes or reverting the affected sprint change, never by bypassing + admission or weakening private-session boundaries. + +Follow-up backlog: a separate TUI `input_pending` investigation; native staged diagnostics without +payload disclosure; unrelated UI warnings and settings improvements. + +## 10. Evidence for this planning step + +This document is based on source, existing tests and contract review. Writing it does not perform +implementation, new test execution, native reproduction or deployment. Sprint acceptance remains +work to be verified. diff --git a/docs/collaboration-refactor-results.ko.md b/docs/collaboration-refactor-results.ko.md new file mode 100644 index 0000000..2069d3d --- /dev/null +++ b/docs/collaboration-refactor-results.ko.md @@ -0,0 +1,131 @@ +# 협업 경계 리팩터링 — 구현과 검증 결과 + +[English](collaboration-refactor-results.md) · [작업 계획](collaboration-refactor-plan.ko.md) + +2026-09-21 · `263f255`를 기준으로 작업 트리에 구현. +**S0–S3 구현, S4 로컬 인수 검증 통과. 네이티브 플랫폼 검증은 남아 있다.** +0.8.3 버전 변경 이전의 구현 검증 기록이며 배포 인증 문서는 아니다. 후속 배포 범위와 남은 네이티브 검증은 [0.8.3 변경 기록](../CHANGELOG.md)을 확인한다. 서명 자격 증명은 릴리스 CI에서만 사용한다. + +## 책임 분리 + +| 담당 | 구현 | 책임 | +| --- | --- | --- | +| 연결 레지스트리 | `crates/core/src/connections.rs` | 연결 승인 정책, 살아 있는 연결 ID, 수명, revision이 있는 이벤트·스냅샷 | +| 협업 조정 | `crates/core/src/collaboration.rs` | 세션 목록, 제어 요청·사용자 승인·되돌려주기·이동의 세션 간 배타 처리 | +| 프로토콜 / 에이전트 서비스 | core의 `protocol.rs`, `agent_commands.rs` | 요청·응답 계약과 에이전트 작업. IPC는 전송과 대기를 담당 | +| 참여 상태 | `crates/core/src/session/participation.rs` | 기존 Session 잠금 안에서 공유 대상과 세대 관리 | +| 대기 작업 | `crates/core/src/session/pending_work.rs` | 명령 검토·제안·유예·제어 요청의 범위별 취소 | +| 사용자 전환 서비스 | `crates/frontend/src/surface_commands.rs`, `automation.rs` | 창 권한 확인, 기록 자원 준비, 외부 입력 차단 경계, 공유 전환 | +| UI 상태 | `frontends/tauri/src/lib/collaboration/` | 타입 계약, 연결 reducer, 세션 스냅샷 적용, 오래된 읽기 방지, 요청 진행 상태 | +| 표시 컴포넌트 | `DecisionCard.svelte`, 기존 interaction dock | 공통 카드·버튼 영역·오류·진행 상태·동작 줄이기 | + +새 crate, 모듈별 잠금, 플러그인 런타임을 추가하지 않았다. 임베더를 위해 기존 `ipc`의 공개 +타입 이름은 재노출한다. AppleScript 용어·이벤트 코드, D-Bus 작업, 외부 응답 형식과 설정 +저장 형식은 유지한다. + +### 연결 승인과 공유는 별개다 + +비공개·빈 화면·셸 준비 중·설정창 안에서도 연결 요청을 처리할 수 있다. 비공개 탭에서도 +에이전트 설치와 앱 전체 연결 승인 설정을 열 수 있고, 세션 정책은 공유 탭에만 적용한다. +연결을 승인해도 비공개 화면 공개, 참여자 선택, 입력 제어권 부여는 자동으로 일어나지 않는다. + +연결 이벤트와 스냅샷에 revision을 부여했다. UI는 누락된 이벤트를 보정하고 오래된 응답이 +이미 처리한 요청을 되살리지 않도록 한다. 실패한 요청은 재시도할 수 있으며, 처리 완료 응답이나 +권위 있는 스냅샷으로 해소된 요청만 제거한다. 이름이 같은 재연결도 다른 연결 ID를 사용한다. + +### 공유 전환은 사전 검증 후 확정한다 + +살아 있는 참여자 ID 확인과 기록 파일 준비를 먼저 수행한다. 외부 입력의 기존 operation 잠금으로 +실제 전달을 막아 둔 상태에서 Session 잠금 안의 사전 검증을 수행한다. 성공한 경우에만 외부 +핸들을 회수하고 대기 입력을 비운다. 입력 상태나 기록 자원 때문에 실패하면 비공개 상태·세대· +외부 입력 권한을 유지한다. 공유 대상을 바꾸기 위해 PTY나 프로세스를 새로 만들지 않는다. + +내부 소유자 API에 `sharing_state`를 추가하고, 공유 적용에는 선택을 읽은 `expectedRevision`을 +전달한다. 오래된 요청은 외부 작성자를 중단하기 전에 `sharing_changed`로 거부한다. UI는 최신 +선택을 명시적으로 다시 불러오도록 하며 자동으로 적용하지 않는다. 기존 `sharing_participants` +응답은 유지하고, revision 없는 기존 소유자 호출도 호환된다. 선택한 연결의 수명은 확정 직전에 +다시 검사한다. + +공유의 잠금 순서는 외부 operation → Session이다. 제어권 전환은 조정 잠금과 복사한 세션 +목록을 이용하고, 짧은 동기 전환 중에만 세션 잠금을 유지한다. 목록 잠금은 세션 잠금 전에 +해제하며, 사용자 승인을 기다리는 동안 이 잠금들을 유지하지 않는다. 연결 콜백은 레지스트리 +잠금을 해제한 뒤 호출하고 Session 잠금을 획득하지 않아야 한다. + +### 취소 규칙 + +| 원인 | 처리 | +| --- | --- | +| 명시적 제어권 회수 | 현재 임대가 없어도 검토·제안·유예·미응답 제어 요청 취소 | +| 에이전트 반환·이동 | 해당 연결의 대기 작업과 임대 정리 | +| 연결 종료 | 해당 연결의 작업만 정리. 같은 이름의 다른 연결은 독립적 | +| 공유 중단·대상 변경 | 기존의 세션 전체 취소 동작을 유지하고 참여 세대 증가 | +| 프로세스 종료·Observe 전환 | 제어권과 대기 작업을 함께 정리 | +| 사용자 타이핑 | 기존 선점 동작과 원시 입력 비수집 경계 유지 | +| 검토 대기·임대 만료 | 기존 검토 중 임대 갱신과 만료 규칙 유지 | +| 창 가림·백그라운드·최소화 | 접근 제한 추가 없음 | + +의도적으로 바뀐 동작은 명시적 회수·반환 때 검토와 제안을 정리하고, 회수·프로세스 종료 때 +미응답 제어 요청까지 끝내는 것이다. 취소된 검토가 나중에 ENTER를 전달할 수 없다. +기존 TUI `input_pending` 차단 조건은 유지했다. 그 판정 방식의 재설계는 이번 범위가 아니다. + +### 공통 UI와 상태 적용 + +연결 승인·제어권 승인·명령 검토 카드는 같은 표시 컴포넌트를 사용하되 명령과 의미는 구분한다. +키보드·명령 팔레트·카드 버튼은 요청별 진행·오류 상태를 공유한다. 세션과 요청 ID를 호출 전에 +고정해, 탭 전환 후 다른 셸에 결과가 전달되지 않도록 했다. + +세션 읽기는 중복 실행을 합치고 이벤트와 경합한 응답은 다시 읽는다. 이벤트와 스냅샷은 같은 참여·요청 변환 함수를 사용한다. 전체 상태 갱신으로 누락된 +상태와 유예 표시를 복구하며, 유예 타이머는 이벤트 경로와 같은 단조 증가 시계를 사용한다. 참여자 갱신이 공유 오류를 지우지 않는다. 영문·한국어는 i18n을 +통하며, 토스트는 승인 버튼을 가리지 않도록 작업 영역 위에 표시한다. + +## 검증 + +| 계층 | 결과 | +| --- | --- | +| `cargo test --locked` | 246개 통과. 외부 SSH 실험실 테스트 1개는 기본 실행에서 제외 | +| 실제 SSH 실험실 테스트 별도 실행 | 암호 오류 후 재시도, 인증, 같은 세션 공유, 암호 비노출·비공개 기록 역수집 방지 통과 | +| 프런트엔드 단위 테스트 | 46개 통과. 새 연결·상태 경합·복구 사례 12개 포함 | +| 프런트엔드 빌드 | 통과. 기존 Svelte 경고 17개는 남아 있음 | +| 문서 사이트 빌드 | 통과. 29개 페이지 생성 | +| 배포 도구 테스트 / 버전 검사 | 78개 통과. 버전 선언은 0.8.2로 일치 | +| 실제 App + 계약 fixture | 승인 실패·재시도, 공유 오류 유지·오래된 선택 새로고침, 비공개·빈 화면·준비 중·설정창 승인, 한국어 좁은 화면·키보드·동작 줄이기 통과 | +| 실제 App + Rust Harness + 지속 연결 에이전트 | 공유 중단 → 비공개 연결 승인 → 참여자 선택 → 제어권 승인 → 별도 명령 검토·실행 → 공유 중단 통과 | + +실제 Harness 테스트는 공유 변경 전에 셸 변수를 설정한 뒤, 에이전트 실행 결과로 그 값이 +독립된 출력 줄에 나타나는지 확인한다. 단순히 입력 명령이나 이전 출력이 보이는 것만으로는 +통과하지 않는다. 임시 소켓·설정을 사용했고 SSH에는 합성 인증 정보만 사용했다. 실험용 +컨테이너는 이전의 중지 상태로 돌려놓았다. 로컬 화면 증거는 `artifacts/collaboration-refactor/`에 +있으며 공개 소스 목록에는 포함하지 않는다. + +### 다시 실행하기 + +```sh +cargo test --locked +cargo build --locked -p conn-browser-harness +cd frontends/tauri +npm test +npm run build +npm run test:collaboration +npm run test:collaboration:harness +``` + +브라우저 검사는 잠금 파일에 고정한 Playwright를 사용한다. `frontends/tauri`에서 +`npx playwright install chromium`을 먼저 실행한다. CI도 같은 브라우저를 설치하며 두 흐름을 +Required checks에 포함한다. 초기 CLI 방식은 CI에서 CDP 연결이 끊겨, 배포 준비 중 지속적인 +드라이버와 명시적인 전환 완료 대기로 바꿨다. 검증 항목은 유지했다. + +## 남은 네이티브 인수 검증 + +- **Linux GUI / D-Bus:** `gdk-3.0` 개발 메타데이터가 없어 컴파일 중단. 샌드박스 밖에서도 + 확인했다. `.github/actions/setup/action.yml`의 데스크톱 의존성을 준비한 뒤 격리된 네이티브 + 어댑터 검증을 수행해야 한다. +- **macOS Apple Silicon:** 이전 SSH 주소의 호스트 키 확인에 실패했다. 신뢰 검증을 우회하지 + 않았다. 확인된 맥에서 이 변경본을 빌드하고 앱 종료·실행 상태의 AppleScript, 숨김·마스킹 + 인증, 비공개 창의 연결 승인, 같은 세션 공유, 백그라운드 관찰·제어, 회수와 공유 중단을 검증해야 + 한다. 다른 설치본이 아닌 빌드한 앱의 스크립팅 사전도 확인해야 한다. 배포에는 서명된 설치본과 + 업데이트 검증이 추가로 필요하다. +- **Windows:** 이 환경에는 네이티브 실행 환경이 없다. 기존 Windows CI에서 빌드·테스트 후 + 실제 앱에서 MCP 연결 승인·공유·회수·종료·재연결을 확인해야 한다. + +브라우저·코어 통과로 위 항목을 대체하지 않는다. 이번 작업 트리의 패키징·서명·설치·업데이트 +배포는 검증하지 않았다. diff --git a/docs/collaboration-refactor-results.md b/docs/collaboration-refactor-results.md new file mode 100644 index 0000000..6d393c5 --- /dev/null +++ b/docs/collaboration-refactor-results.md @@ -0,0 +1,139 @@ +# Collaboration boundaries — implementation and validation + +[한국어](collaboration-refactor-results.ko.md) · [Plan](collaboration-refactor-plan.md) + +2026-09-21 · Implementation based on `263f255`, in the working tree. +**S0–S3 implemented; S4 local acceptance passed, native platform acceptance remains open.** +This report records implementation validation before the 0.8.3 version bump. It is not a release certification. See the [0.8.3 changelog](../CHANGELOG.md) for the subsequent release scope and remaining native verification. Signing credentials stay in release CI. + +## Responsibility boundaries + +| Owner | Implementation | Responsibility | +| --- | --- | --- | +| Connection registry | `crates/core/src/connections.rs` | Admission policy, live connection IDs, lifetime, revisioned owner events/snapshots | +| Collaboration coordinator | `crates/core/src/collaboration.rs` | Session catalog; common cross-session control requests, owner grants, hand-back and navigation | +| Protocol / agent service | `protocol.rs`, `agent_commands.rs` in `conn-core` | Request/result contracts and agent operations; IPC handles transport and waiting | +| Participation | `crates/core/src/session/participation.rs` | Audience and generations inside the existing Session lock | +| Pending work | `crates/core/src/session/pending_work.rs` | Scoped cancellation of reviews, proposals, grace and control requests | +| Owner transition | `crates/frontend/src/surface_commands.rs`, `automation.rs` | Owner authorization, audit preparation, external-writer fencing and participation commit | +| UI state | `frontends/tauri/src/lib/collaboration/` | Typed contracts, admission reducer, session snapshot application, stale-read reconciliation and decision progress | +| Presentation | `DecisionCard.svelte` and existing interaction dock | Shared card layout, action slots, errors, busy state and reduced-motion handling | + +No additional crate, per-module mutex, or plugin runtime was introduced. `ipc` re-exports existing +public names for embedders. Existing AppleScript terminology, event codes, D-Bus operations and +external return shapes are unchanged. The existing settings format is retained. + +### Admission is independent of sharing + +Pending connections can be answered in private, empty and preparing views, and inside Settings. +Agents setup and app-wide admission settings remain available on private tabs; per-session policy +controls remain scoped to shared tabs. Admitting a connection does not reveal a private session, +select it as a participant, or grant input authority. + +Admission snapshots/events use a monotonically increasing revision. The UI reconciles missed events +and rejects stale per-connection updates. A failed decision remains actionable. Confirmed decisions +and authoritative reconciliation remove requests. Same-name reconnects retain distinct connection IDs. + +### Sharing commits after successful preflight + +The owner service validates selected live IDs and prepares the audit resource before mutation. +The external writer's existing operation lock fences delivery; Session preflight runs under its lock. +Only a successful commit revokes the external handle and drains queued payloads. A failed input or +history precondition preserves private state, generations and external input. No lease or PTY is +replaced to change the audience. + +Lock order is external operation → Session for sharing. Control transitions use the coordinator lock, +a cloned session catalog and the existing session locks, held only for the synchronous transition. +The catalog lock is released before session locks; no approval wait runs under these locks. Registry +listeners run after releasing the registry lock and must not acquire Session locks. + +### Cancellation semantics + +| Trigger | Result | +| --- | --- | +| Explicit human takeover | Cancel reviews, proposals, grace and unanswered control requests, even without a held lease | +| Agent release or move | Cancel that connection's pending work and release its lease | +| Connection close | Cancel only that connection's work; another connection with the same name is independent | +| Stop/change sharing | Preserve existing whole-session cancellation behavior; advance participation generation | +| Process exit / switch to Observe | End pending work as well as authority | +| Human typing | Preserve existing preemptive input behavior and raw-input privacy boundary | +| Review wait / lease timeout | Preserve the existing review-wait renewal policy and expiry rules | +| Window blur, background or minimization | No added access restriction | + +The intentional changes are cleanup of reviews/proposals after explicit take/release, and cleanup of +control requests after take/process exit. A resolved or cancelled review cannot later deliver ENTER. +The existing TUI `input_pending` guard is preserved; its heuristic is not redesigned here. + +### UI state and reusable decisions + +Admission, control and command-review cards use one presentation component while retaining distinct +commands and labels. Keyboard review responses and card buttons share per-request busy/error state. +Targets are captured by session/request ID, so an awaited response cannot follow a changed active tab. +Session reads are coalesced; reads racing events retry before applying. Full polling repairs missed +state, including grace state. Sharing errors survive participant refreshes. English and Korean use +i18n entries. Toasts sit above the interaction dock instead of covering its controls. + +### Sharing concurrency and UI state + +The owner-only `sharing_state` command returns participants and their participation revision together. +The UI sends that revision as `expectedRevision` when applying a selection. A stale request returns +`sharing_changed` before revoking external input; the user reloads the current selection and applies +explicitly. Live connection IDs are checked again at commit. Existing `sharing_participants` responses +and owner calls without a revision remain compatible. + +Session events now have a scoped discriminated union. Event and snapshot projection share the same +participation/request helpers, and command-palette decisions use the same actions as cards and keyboard +review. Recovered grace countdowns use the same monotonic clock as live events and retain their start +across polling. + +## Validation evidence + +| Layer | Result | +| --- | --- | +| `cargo test --locked` | 246 passed; the external SSH-lab test is ignored in this default command | +| Real SSH lab test, explicitly enabled | Passed: wrong-password retry, successful authentication, same-session sharing, hidden credential exclusion and no private-history backfill | +| Frontend unit tests | 46 passed, including 12 new admission/reconciliation cases | +| Frontend production build | Passed; 17 existing Svelte warnings remain | +| Documentation site build | Passed; 29 pages generated | +| Release-tool unit tests / version check | 78 passed; version declarations still agree on 0.8.2 | +| Production App + contract fixture | Passed: failure/retry, persistent sharing errors, stale-selection reload, private/empty/preparing/Settings admission, Korean narrow layout, keyboard and reduced-motion checks | +| Production App + real Rust Harness + persistent agent | Passed: stop sharing, private admission, explicit participation, control grant, separate command review, actual command output and sharing stop | + +The real Harness test sets a shell variable before sharing changes, then requires its standalone +output after agent execution. Merely seeing a typed command or a prior terminal line does not pass. +Tests use disposable sockets/configuration; the SSH fixture uses synthetic credentials. The test +container was returned to its prior stopped state. Local screenshots live under +`artifacts/collaboration-refactor/` and are not part of the public source inventory. + +### Repeat the portable checks + +```sh +cargo test --locked +cargo build --locked -p conn-browser-harness +cd frontends/tauri +npm test +npm run build +npm run test:collaboration +npm run test:collaboration:harness +``` + +Browser checks use the lockfile-pinned Playwright driver. Run `npx playwright install chromium` +from `frontends/tauri` first. CI installs the matching browser and includes both flows in Required +checks. The initial CLI-based runner lost CDP connections in CI; the release preparation switched +to a persistent driver and explicit transition-completion waits without dropping assertions. + +## Remaining native acceptance + +- **Linux native GUI / D-Bus:** compilation stopped because `gdk-3.0` development metadata is absent, + also confirmed outside the filesystem sandbox. Install the desktop dependencies used by + `.github/actions/setup/action.yml`, then build and run the isolated native adapter tests. +- **macOS Apple Silicon:** the prior SSH endpoint failed host-key verification. No trust override was + made. On a verified Mac, build this exact revision and validate cold/warm AppleScript launch, + hidden/masked login, connection approval in the private window, sharing of that same session, + background observation/control, takeover and stop. Check the scripting dictionary on the built + app, not another installed copy. Signed installer/updater acceptance is still required for release. +- **Windows:** no native runtime is available here. Compile/test on the existing Windows CI runner, + then exercise MCP admission, sharing, takeover and close/reconnect in the native app. + +Browser and core passes do not substitute for these native checks. Packaging, signing, installation +and update delivery have not been exercised with this working-tree revision. diff --git a/docs/getting-started.ko.md b/docs/getting-started.ko.md index 7717609..079010a 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.2`로 버전을 고정할 수 있습니다. 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.3`로 버전을 고정할 수 있습니다. AppImage는 FUSE 2가 필요합니다. Ubuntu 24.04 이상에서는 `sudo apt install libfuse2t64`를 실행하세요. 두 방법 모두 이후에는 앱 안에서 업데이트됩니다. 셸에 넘기기 전에 스크립트를 읽어 보셔도 좋습니다: [`scripts/install.sh`](../scripts/install.sh). 직접 파일을 받으려면: -OS와 CPU에 맞는 [v0.8.2 프리뷰](https://github.com/eggp-dev/conn/releases/tag/v0.8.2)를 받으세요. 실제 서명 상태와 실행 검증 결과는 릴리스 안내에서 확인할 수 있습니다. +OS와 CPU에 맞는 [v0.8.3 프리뷰](https://github.com/eggp-dev/conn/releases/tag/v0.8.3)를 받으세요. 실제 서명 상태와 실행 검증 결과는 릴리스 안내에서 확인할 수 있습니다. | 플랫폼 | 다운로드 | 설치 | |---|---|---| -| Ubuntu x64 | [`.deb`](https://github.com/eggp-dev/conn/releases/download/v0.8.2/conn-v0.8.2-x86_64-unknown-linux-gnu-desktop.deb) | 아래 명령으로 설치한 뒤 앱 목록에서 Conn을 엽니다. | -| Ubuntu x64 | [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.2/conn-v0.8.2-x86_64-unknown-linux-gnu-desktop.AppImage) | 실행 권한을 준 뒤 파일을 엽니다. | -| macOS Apple Silicon | [`.dmg`](https://github.com/eggp-dev/conn/releases/download/v0.8.2/conn-v0.8.2-aarch64-apple-darwin-desktop.dmg) | DMG를 열고 Conn을 Applications로 옮긴 뒤 그곳에서 실행합니다. | -| Windows x64 | [설치 `.exe`](https://github.com/eggp-dev/conn/releases/download/v0.8.2/conn-v0.8.2-x86_64-pc-windows-msvc-setup.exe) | 설치 파일을 실행하고 시작 메뉴에서 Conn을 엽니다. | +| Ubuntu x64 | [`.deb`](https://github.com/eggp-dev/conn/releases/download/v0.8.3/conn-v0.8.3-x86_64-unknown-linux-gnu-desktop.deb) | 아래 명령으로 설치한 뒤 앱 목록에서 Conn을 엽니다. | +| Ubuntu x64 | [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.3/conn-v0.8.3-x86_64-unknown-linux-gnu-desktop.AppImage) | 실행 권한을 준 뒤 파일을 엽니다. | +| macOS Apple Silicon | [`.dmg`](https://github.com/eggp-dev/conn/releases/download/v0.8.3/conn-v0.8.3-aarch64-apple-darwin-desktop.dmg) | DMG를 열고 Conn을 Applications로 옮긴 뒤 그곳에서 실행합니다. | +| Windows x64 | [설치 `.exe`](https://github.com/eggp-dev/conn/releases/download/v0.8.3/conn-v0.8.3-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.2-x86_64-unknown-linux-gnu-desktop.deb +sudo apt install ./conn-v0.8.3-x86_64-unknown-linux-gnu-desktop.deb ``` AppImage를 선택했다면 다음과 같이 실행합니다. ```sh -chmod +x ./conn-v0.8.2-x86_64-unknown-linux-gnu-desktop.AppImage -./conn-v0.8.2-x86_64-unknown-linux-gnu-desktop.AppImage +chmod +x ./conn-v0.8.3-x86_64-unknown-linux-gnu-desktop.AppImage +./conn-v0.8.3-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.2/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.3/SHA256SUMS)를 받으세요. Linux는 `sha256sum --ignore-missing -c SHA256SUMS`, macOS는 `shasum -a 256 <파일>` 결과와 해당 줄 비교, Windows는 `Get-FileHash <파일> -Algorithm SHA256`을 사용합니다. 체크섬은 파일 손상을 확인하며 코드 서명과는 별개입니다. ## 2. 터미널 설정 @@ -173,3 +173,11 @@ Apple Silicon macOS, Windows x64, Linux AppImage에서 앱 내 설치를 지원 **설정 → 확장**에서 터미널 테마를 고르거나 JSON 파일로 테마를 설치합니다. [확장 안내](extensions.ko.md) + +### 연결 승인과 공유 복구 (미배포) + +**허용 / 거부** 연결 카드는 비공개·셸 준비 중 화면과 설정창 안에서도 표시됩니다. 연결을 +허용해도 참여자를 선택하고 공유를 시작하기 전까지 터미널은 비공개입니다. 미완성 입력 때문에 +공유가 실패하면 터미널에서 입력을 완료하거나 취소한 뒤 다시 시도하세요. 실패한 전환은 외부 +입력 권한을 유지합니다. 다른 화면에서 선택이 바뀌었다면 **선택 다시 불러오기**로 현재 참여자를 +확인한 뒤 적용하세요. [검증 범위](collaboration-refactor-results.ko.md)를 참고하세요. diff --git a/docs/getting-started.md b/docs/getting-started.md index 419659c..f2e83f3 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.2` 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.3` 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.2 preview](https://github.com/eggp-dev/conn/releases/tag/v0.8.2) for your OS and CPU. See the release notes for the exact signing and runtime verification results. +Download the [v0.8.3 preview](https://github.com/eggp-dev/conn/releases/tag/v0.8.3) 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.2/conn-v0.8.2-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.2/conn-v0.8.2-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.2/conn-v0.8.2-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.2/conn-v0.8.2-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.3/conn-v0.8.3-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.3/conn-v0.8.3-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.3/conn-v0.8.3-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.3/conn-v0.8.3-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.2-x86_64-unknown-linux-gnu-desktop.deb +sudo apt install ./conn-v0.8.3-x86_64-unknown-linux-gnu-desktop.deb ``` Or, for AppImage: ```sh -chmod +x ./conn-v0.8.2-x86_64-unknown-linux-gnu-desktop.AppImage -./conn-v0.8.2-x86_64-unknown-linux-gnu-desktop.AppImage +chmod +x ./conn-v0.8.3-x86_64-unknown-linux-gnu-desktop.AppImage +./conn-v0.8.3-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.2/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.3/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 @@ -178,3 +178,11 @@ Earlier private input/output is not added to history. **Settings → Extensions** contains terminal themes, including themes you install from a JSON file. [Extension guide](extensions.md) + +### Connection approval and sharing recovery (unreleased) + +The **Allow / Deny** connection card also appears in private and preparing windows and inside Settings. +Allowing the connection keeps the terminal private until you select that connection and start sharing. +If sharing reports unfinished input, finish or cancel it in the terminal and try again; the failed change +keeps external input available. If another view changed the selection, use **Reload selection**, review +the current participants and apply again. See [validation scope](collaboration-refactor-results.md). diff --git a/docs/platform-support.ko.md b/docs/platform-support.ko.md index af8368a..7f68cc9 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.2)로 제공합니다. 모든 데스크톱 패키지는 같은 Rust 엔진과 Svelte UI를 사용하며 브라우저 하네스는 개발용 어댑터입니다. +Conn은 MIT 라이선스 소스와 네이티브 바이너리를 [GitHub Releases](https://github.com/eggp-dev/conn/releases/tag/v0.8.3)로 제공합니다. 모든 데스크톱 패키지는 같은 Rust 엔진과 Svelte UI를 사용하며 브라우저 하네스는 개발용 어댑터입니다. ## 다음 프리뷰 릴리스 대상 diff --git a/docs/platform-support.md b/docs/platform-support.md index e166d4c..8592af9 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.2). 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.3). All desktop packages use the same Rust engine and Svelte UI. The browser harness is a development adapter. ## Targets for upcoming preview releases diff --git a/docs/protocol.md b/docs/protocol.md index fc22844..0616704 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -168,3 +168,18 @@ view and take a fresh snapshot. not another public socket role. Only the human owner may transition its private session to shared participation. [Native extensions](extensions.md) are declarative themes; they cannot call owner operations through agent IPC either. + +## Owner bridge additions (unreleased; unavailable on the agent socket) + +- `admission_snapshot`: `{revision,pending}`. Admission events carry the same app-wide revision; + request identity is the live `connId`. UI reconciliation discards stale per-connection events. +- `sharing_state({session})`: `{revision,participants}` read together under the Session lock. +- `set_sharing({session,shared,connectionIds,expectedRevision?})`: a stale participation revision + returns `sharing_changed` before external-writer revocation. New UI calls always send it; existing + owner calls and the `sharing_participants` array result remain compatible. +- Full owner status includes `scheduledRemainingMs` for recovering a missed Grace event. Agent + status remains limited; these owner commands do not become MCP tools. + +한국어: 위 API는 앱 소유자 경로에만 추가되며 에이전트 소켓에서는 사용할 수 없습니다. +연결 승인과 참여 선택은 각각의 revision으로 경합을 처리합니다. 오래된 공유 선택은 외부 입력을 +회수하기 전에 거절하며, UI는 다시 불러온 선택을 사용자가 확인한 뒤 적용하도록 합니다. diff --git a/frontends/tauri/package.json b/frontends/tauri/package.json index baff849..7efa2c7 100644 --- a/frontends/tauri/package.json +++ b/frontends/tauri/package.json @@ -1,14 +1,16 @@ { "name": "conn-desktop", "private": true, - "version": "0.8.2", + "version": "0.8.3", "type": "module", "scripts": { "dev": "vite", "build": "svelte-check --threshold error && vite build", "tauri": "node scripts/tauri.mjs", "test:browser": "node scripts/browser-test.mjs", - "test": "node tests/timeline/events.test.ts && node tests/icon/state.test.ts && node tests/connections/status.test.ts && node tests/terminal/input.test.ts" + "test": "node tests/timeline/events.test.ts && node tests/icon/state.test.ts && node tests/connections/status.test.ts && node tests/terminal/input.test.ts && node tests/collaboration/state.test.ts", + "test:collaboration": "node scripts/collaboration-test.mjs", + "test:collaboration:harness": "node scripts/collaboration-harness-test.mjs" }, "dependencies": { "@conn/brand": "*", @@ -21,6 +23,7 @@ "@sveltejs/vite-plugin-svelte": "^5.1.1", "@tauri-apps/cli": "^2", "@tsconfig/svelte": "^5.0.8", + "playwright": "1.63.0", "svelte": "^5.57.0", "svelte-check": "^4.7.6", "typescript": "^5.6.0", diff --git a/frontends/tauri/scripts/collaboration-browser.mjs b/frontends/tauri/scripts/collaboration-browser.mjs new file mode 100644 index 0000000..5f21853 --- /dev/null +++ b/frontends/tauri/scripts/collaboration-browser.mjs @@ -0,0 +1,31 @@ +// One browser connection per test run; context isolation per fixture. +import { chromium } from 'playwright'; +export async function collaborationBrowser() { + const browser = await chromium.launch(); + let context, page; + const open = async url => { + await context?.close(); + context = await browser.newContext({ viewport: {width: 1280, height: 900} }); + page = await context.newPage(); + page.setDefaultTimeout(15000); + await page.goto(url); + }; + const run = async (action, ...args) => { + switch (action) { + case 'open': return open(args[0]); + case 'close': return browser.close(); + case 'gone': return page.locator(args[0]).waitFor({state: 'detached'}); + case 'wait': return page.locator(args[0]).waitFor({state: 'visible'}); + case 'click': return page.locator(args[0]).click(); + case 'type': return page.locator(args[0]).pressSequentially(args[1]); + case 'press': return page.keyboard.press(args[0]); + case 'eval': return page.evaluate(args[0]); + case 'screenshot': return page.screenshot({path: args[0]}); + case 'set': + if (args[0] === 'viewport') return page.setViewportSize({width: Number(args[1]), height: Number(args[2])}); + if (args[0] === 'media') return page.emulateMedia({colorScheme: args[1], reducedMotion: 'reduce'}); + } + throw Error(`Unknown browser action: ${action}`); + }; + return {run, value: source => page.evaluate(source), open}; +} diff --git a/frontends/tauri/scripts/collaboration-harness-test.mjs b/frontends/tauri/scripts/collaboration-harness-test.mjs new file mode 100644 index 0000000..2e02809 --- /dev/null +++ b/frontends/tauri/scripts/collaboration-harness-test.mjs @@ -0,0 +1,52 @@ +import { collaborationBrowser } from './collaboration-browser.mjs'; +// Real Rust Harness + production UI + a persistent socket agent. No alternate UI. +import {spawn} from 'node:child_process'; +import {mkdtempSync,writeFileSync,existsSync,mkdirSync,openSync} 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-collaboration-')); +const port=process.env.CONN_HARNESS_UI_PORT??'1437', backend=process.env.CONN_HARNESS_PORT??'1438'; +const output=resolve('../../artifacts/collaboration-refactor');mkdirSync(output,{recursive:true}); +const env={...process.env,CONN_TEST_PORT:backend,CONN_TEST_ORIGIN:`http://127.0.0.1:${port}`,CONN_TEST_STATE:dir};delete env.npm_config_prefix; +writeFileSync(join(dir,'app.json'),JSON.stringify({mode:'autopilot',gate:true,pacing:{enterGraceMs:0,minWriteIntervalMs:0,leaseTtlSecs:120,approvalTtlSecs:120}})); +const log=openSync(join(dir,'harness.log'),'w'); +const server=spawn(resolve('../../target/debug/conn-browser-harness'),[dir],{env,stdio:['ignore',log,log]}); +let vite,socket;const pending=new Map();let seq=0,buffer=''; +const {run} = await collaborationBrowser(); +const delay=ms=>new Promise(r=>setTimeout(r,ms)); +const call=(method,params={})=>new Promise((resolve,reject)=>{const id=++seq;const 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');}); +try { + for(let i=0;!existsSync(join(dir,'connection.json'));i++){if(i>100||server.exitCode!==null)throw Error('Harness 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],{env,stdio:'ignore'}); + for(let i=0;;i++){try{if((await fetch(`http://127.0.0.1:${port}/`)).ok)break;}catch{}if(i>100)throw Error('Vite startup failed');await delay(100);} + await run('open',`http://127.0.0.1:${port}/`);await run('wait','.xterm-helper-textarea'); + await run('click','.xterm-helper-textarea');await run('type','.xterm-helper-textarea','export CONN_COLLAB_PROCESS=retained_42');await run('press','Enter');await delay(350); + await run('wait','button.dot:not(.hidden)');await run('click','button.dot:not(.hidden)');await run('wait','.center');await run('click','.sharing-row button');await run('click','.sharing footer .ghost');await run('wait','.sharing-trigger'); + socket=net.createConnection(join(dir,'conn.sock'));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 message=JSON.parse(buffer.slice(0,at));buffer=buffer.slice(at+1);const p=pending.get(message.id);if(p){clearTimeout(p.timer);pending.delete(message.id);message.error?p.reject(Object.assign(Error(message.error.message),message.error)):p.resolve(message.result);}}}); + const hello=await call('hello',{kind:'agent',agentId:'collaboration-test'});assert.equal(hello.admission,'pending'); + await run('wait','.decision-card');await run('screenshot',resolve(output,'harness-private-admission.png'));await run('click','.decision-card .primary');await delay(300);await run('screenshot',resolve(output,'harness-after-allow.png')); + const hidden=await call('list_tabs');assert.equal(hidden.tabs,0); + await run('click','.sharing-trigger');await run('click','.sharing-row button');await run('click','.sharing input[type=checkbox]');await run('click','.sharing footer .btn:not(.ghost)'); + await delay(200);assert.equal((await call('list_tabs')).tabs,1); + const control=call('request_control',{reason:'Verify the existing shell survives sharing.'}); + await run('wait','.decision-card');await run('click','.decision-card .primary');await control; + await call('type',{text:'printf "%s\\n" "$CONN_COLLAB_PROCESS"'}); + const execution=await call('send_key',{key:'ENTER',intent:'Read the marker already set in this shell.'}); + if(execution.status === 'pending') { await run('wait','.decision-card .btn.ok');await run('click','.decision-card .btn.ok'); } + else assert.equal(execution.status,'executed'); + let snapshot; + for(let i=0;i<20;i++){await delay(100);snapshot=await call('snapshot');if(snapshot.screen.some(row=>row.trim()==='retained_42'))break;} + assert.ok(snapshot.screen.some(row=>row.trim()==='retained_42'), "standalone command output proves process state survived"); + await call('release_control'); + await run('screenshot',resolve(output,'harness-shared-execution.png')); + await run('wait','button.dot:not(.hidden)');await run('click','button.dot:not(.hidden)');await run('wait','.center');await run('click','.sharing-row button');await run('click','.sharing footer .ghost'); + await delay(150);assert.equal((await call('list_tabs')).tabs,0); + console.log('PASS real Harness: private admission, selected sharing, owner control grant, execution, same PTY marker, stop sharing'); + console.log(`Isolated evidence: ${dir}`); +} finally { + for(const p of pending.values())clearTimeout(p.timer);socket?.destroy(); + try{await run('close');}catch{}vite?.kill();server.kill(); +} diff --git a/frontends/tauri/scripts/collaboration-test.mjs b/frontends/tauri/scripts/collaboration-test.mjs new file mode 100644 index 0000000..7b56d14 --- /dev/null +++ b/frontends/tauri/scripts/collaboration-test.mjs @@ -0,0 +1,66 @@ +import { collaborationBrowser } from './collaboration-browser.mjs'; +// Runs the production App against an isolated contract fixture. Uses an isolated Playwright browser. +import { spawn } from 'node:child_process'; +import { strict as assert } from 'node:assert'; +import { resolve } from 'node:path'; +import { mkdirSync } from 'node:fs'; +const port=process.env.CONN_UI_TEST_PORT ?? '1435'; +const base=`http://127.0.0.1:${port}/tests/collaboration/`; +const output=resolve('../../artifacts/collaboration-refactor');mkdirSync(output,{recursive:true}); +const {run, value, open: openBrowser} = await collaborationBrowser(); +let server; +async function openFixture(query) { await openBrowser(base+query); } +try { + try { await fetch(base); } catch { server=spawn(process.execPath,['node_modules/vite/bin/vite.js','--host','127.0.0.1','--port',port],{stdio:'ignore'}); } + for(let i=0;i<100;i++){try{if((await fetch(base)).ok)break;}catch{}if(i===99)throw Error('Vite did not start');await new Promise(r=>setTimeout(r,100));} + await openFixture('?fail-admission');await run('wait','.decision-card'); + assert.equal(await value('document.querySelectorAll(".decision-card").length'),1); + await run('click','.decision-card .primary'); + assert.match(await value('document.querySelector("[role=alert]").textContent'),/try again/); + assert.equal(await value('document.querySelector(".decision-card .primary").disabled'),false); + await run('screenshot',resolve(output,'admission-retry.png')); + await run('click','.decision-card .primary'); + await run('gone','.decision-card'); + assert.equal(await value('document.querySelectorAll(".decision-card").length'),0); + console.log('PASS private admission visible, failure retained, retry resolves'); + await openFixture('?fail-sharing');await run('wait','.decision-card');await run('click','.decision-card .primary'); + await run('click','.sharing-trigger');await run('click','.sharing-row button'); + await run('wait','.sharing');await run('click','.sharing input[type=checkbox]');await run('click','.sharing footer .btn:not(.ghost)'); + const error=await value('document.querySelector(".sharing [role=alert]").textContent'); + await new Promise(r=>setTimeout(r,2800)); + assert.equal(await value('document.querySelector(".sharing [role=alert]").textContent'),error); + assert.match(error,/input|typing|command/i); + await run('screenshot',resolve(output,'sharing-failure.png')); + console.log('PASS sharing error survives participant polling'); + await openFixture('?fail-stale');await run('wait','.decision-card');await run('click','.decision-card .primary'); + await run('click','.sharing-trigger');await run('click','.sharing-row button'); + await run('wait','.sharing');await run('click','.sharing input[type=checkbox]');await run('click','.sharing footer .btn:not(.ghost)'); + assert.match(await value('document.querySelector(".sharing [role=alert]").textContent'),/another window/); + assert.equal(await value('document.querySelector(".sharing footer .btn:not(.ghost)").disabled'),true); + await run('click','.sharing .body > .btn'); + assert.equal(await value('document.querySelector(".sharing input[type=checkbox]").checked'),false); + await run('click','.sharing input[type=checkbox]');await run('click','.sharing footer .btn:not(.ghost)'); + await run('gone','.sharing'); + assert.equal(await value('document.querySelectorAll(".sharing").length'),0); + console.log('PASS stale sharing requires reloading the current selection'); + for (const state of ['empty', 'preparing', 'settings']) { + await openFixture('?'+state);await run('wait','.decision-card'); + assert.equal(await value('document.querySelector(".decision-card .primary").closest("[inert]") === null'),true); + if(state === 'settings') assert.ok((await value('document.querySelector(".sheet").textContent')).includes('Agents')); + await run('click','.decision-card .primary'); + await run('gone','.decision-card'); + assert.equal(await value('document.querySelectorAll(".decision-card").length'),0); + } + console.log('PASS admission in empty, preparing, and settings views'); + await openFixture('?lang=ko&delayed');await run('set','media','dark','reduced-motion');await run('set','viewport','390','700'); + await run('eval','window.dispatchEvent(new Event("fixture:request"))');await run('wait','.decision-card'); + assert.equal(await value('document.documentElement.scrollWidth <= innerWidth'),true); + assert.equal(await value('document.querySelector(".decision-card").getBoundingClientRect().right <= innerWidth'),true); + await run('screenshot',resolve(output,'admission-ko-narrow.png')); + assert.equal(await value('matchMedia("(prefers-reduced-motion: reduce)").matches'),true); + assert.equal(await value('document.querySelector(".decision-card").getAnimations().length'),0); + await run('eval','document.querySelector(".decision-card .primary").focus()');await run('press','Enter'); + await run('gone','.decision-card'); + assert.equal(await value('document.querySelectorAll(".decision-card").length'),0); + console.log('PASS Korean narrow layout, reduced motion, and keyboard approval'); +} finally {try{await run('close');}catch{}server?.kill();} diff --git a/frontends/tauri/src-tauri/tauri.conf.json b/frontends/tauri/src-tauri/tauri.conf.json index 478a8d4..34881df 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.2", + "version": "0.8.3", "identifier": "dev.eggp.conn", "build": { "beforeDevCommand": "npm run dev", diff --git a/frontends/tauri/src/App.svelte b/frontends/tauri/src/App.svelte index e7c0784..1448118 100644 --- a/frontends/tauri/src/App.svelte +++ b/frontends/tauri/src/App.svelte @@ -1,4 +1,10 @@ @@ -393,8 +369,8 @@ {/if} openSettings(st.settingsTab)} onpalette={() => { st.settingsOpen = false; st.centerOpen = false; st.paletteOpen = true; }} ontimeline={() => { st.timelineOpen = !st.timelineOpen; }} /> {#if st.active} { st.paletteOpen = false; st.centerOpen = !st.centerOpen; }} />{/if} +
{#if !st.settingsOpen}{/if}{#if cur().shared}{/if}
{#if !!cur().shared} -
{/if} diff --git a/frontends/tauri/src/components/AdmissionRequest.svelte b/frontends/tauri/src/components/AdmissionRequest.svelte index 74b1fe3..b476ed6 100644 --- a/frontends/tauri/src/components/AdmissionRequest.svelte +++ b/frontends/tauri/src/components/AdmissionRequest.svelte @@ -1,41 +1,28 @@ - {#if r} {#key r.connId} -
-
{r.agentId}{t("admission.asks")}{#if st.admissions.length > 1}{t("admission.more", { n: st.admissions.length - 1 })}{/if}
- {t("admission.desc")} - - - - -
+ +

{t('admission.desc')}

+ {#if st.admissions.length > 1}{t('admission.more', { n: st.admissions.length - 1 })}{/if} + {#snippet actions()} + + + {/snippet} +
{/key} {/if} - - diff --git a/frontends/tauri/src/components/ControlRequest.svelte b/frontends/tauri/src/components/ControlRequest.svelte index cbf54a8..131698e 100644 --- a/frontends/tauri/src/components/ControlRequest.svelte +++ b/frontends/tauri/src/components/ControlRequest.svelte @@ -1,39 +1,27 @@ - -{#if cur().ctlReq} -
-
{r.agentId}{t("ctl.asks")}
- {#if r.reason}{r.reason}{/if} - - - - - - -
+{#if r} + {#key `${st.active}:${r.id}`} + + {#if r.reason}

{r.reason}

{/if} + + {#snippet actions()} + + + + {/snippet} +
+ {/key} {/if} - - diff --git a/frontends/tauri/src/components/DecisionCard.svelte b/frontends/tauri/src/components/DecisionCard.svelte new file mode 100644 index 0000000..bb518d4 --- /dev/null +++ b/frontends/tauri/src/components/DecisionCard.svelte @@ -0,0 +1,20 @@ + +
+
{agent}{label}
+ {@render children?.()} + {#if error}{/if} +
{@render actions()}
+
+ diff --git a/frontends/tauri/src/components/Hold.svelte b/frontends/tauri/src/components/Hold.svelte index 1f4b8b2..cea3782 100644 --- a/frontends/tauri/src/components/Hold.svelte +++ b/frontends/tauri/src/components/Hold.svelte @@ -1,8 +1,10 @@ diff --git a/frontends/tauri/tests/collaboration/main.ts b/frontends/tauri/tests/collaboration/main.ts new file mode 100644 index 0000000..659f7bb --- /dev/null +++ b/frontends/tauri/tests/collaboration/main.ts @@ -0,0 +1,55 @@ +// Contract fixture for the production App. Never imported by the application entrypoint. +import { mockIPC, mockWindows } from '@tauri-apps/api/mocks'; +import { emit } from '@tauri-apps/api/event'; +import { st } from '../../src/lib/store.svelte'; +import { mount } from 'svelte'; +import App from '../../src/App.svelte'; +import '../../src/app.css'; +import { setLang } from '../../src/lib/i18n.svelte'; + +const options = new URLSearchParams(location.search); +setLang(options.get('lang') === 'ko' ? 'ko' : 'en'); +if (options.has('settings')) { st.settingsOpen = true; st.settingsTab = 'agents'; } +let shared = false, admitted = false, admissionFailures = options.has('fail-admission') ? 1 : 0; +let revision = 1; +let staleFailures = options.has('fail-stale') ? 1 : 0; +let waiting = !options.has('delayed'); +window.addEventListener('fixture:request', () => { waiting = true; revision++; void emit('ss:admission', {connId: 7, agentId: 'copilot', state: 'pending', revision}); }); +const profile = { id: 'fixture', name: 'External shell', enabled: true, backend: 'local', shell: 'posix', program: '/bin/bash', args: [], cwd: null, env: {}, target: null, port: null }; +const status = () => ({ shared, externalOrigin: true, externalStarting: options.has('preparing'), externalInputAvailable: !shared, + surfaceAvailable: shared, inputPending: false, processAlive: true, attended: true, profileId: profile.id, profileName: profile.name, + mode: 'autopilot', effectiveMode: 'autopilot', controller: {type: 'human'}, controlGate: true, pending: [], controlRequests: [], + sessionAllows: [], affordanceMask: null, connectedAgents: admitted ? ['copilot'] : [], reviewRequired: true, + pacing: {minWriteIntervalMs: 0, enterGraceMs: 0, leaseTtlSecs: 60, approvalTtlSecs: 300}, revision }); +mockWindows('main'); +mockIPC((command, raw: any) => { + if (command !== 'dispatch') return null; + const { name, args } = raw; + switch (name) { + case 'profiles_catalog': return {config: {version: 1, revision: 0, defaultProfile: profile.id, profiles: [profile]}, availability: {[profile.id]: {available: true, message: 'Ready'}}}; + case 'extensions_status': return {extensions: []}; + case 'start': return {socket: 'fixture', session: options.has('empty') ? null : 'private-1', sessions: options.has('empty') ? [] : ['private-1'], externalPending: options.has('preparing')}; + case 'status': return status(); + case 'pending_admissions': return admitted || !waiting ? [] : [{connId: 7, agentId: 'copilot'}]; + case 'admission_snapshot': return {revision, pending: admitted || !waiting ? [] : [{connId: 7, agentId: 'copilot'}]}; + case 'admission_policy': return {ask: true}; + case 'decide_admission': + if (admissionFailures-- > 0) throw new Error('transport_unavailable'); + admitted = true; revision++; + void emit('ss:admission', {connId: 7, agentId: 'copilot', state: args.allow ? 'granted' : 'denied', revision}); + return {decided: true}; + case 'sharing_participants': return admitted ? [{connId: 7, agentId: 'copilot', selected: shared}] : []; + case 'sharing_state': return {revision, participants: admitted ? [{connId: 7, agentId: 'copilot', selected: shared}] : []}; + case 'set_sharing': + if (staleFailures-- > 0) { revision++; throw new Error('sharing_changed'); } + if (options.has('fail-sharing')) throw new Error('input_pending'); + shared = args.shared; revision++; + void emit('ss:event', {session: 'private-1', event: 'sharing_changed', shared, generation: revision}); + return status(); + case 'agent_clients': return []; + case 'audit_tail': return []; + case 'log': return null; + default: return null; + } +}, {shouldMockEvents: true}); +mount(App, {target: document.getElementById('app')!}); diff --git a/frontends/tauri/tests/collaboration/state.test.ts b/frontends/tauri/tests/collaboration/state.test.ts new file mode 100644 index 0000000..8c8f112 --- /dev/null +++ b/frontends/tauri/tests/collaboration/state.test.ts @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { Admissions } from '../../src/lib/collaboration/admissions.ts'; +import { applySessionEvent, applySessionSnapshot, type SessionSnapshot } from '../../src/lib/collaboration/session.ts'; +import type { TabState } from '../../src/lib/store.svelte.ts'; +const pending = (connId: number, revision: number) => ({connId, agentId: 'same-name', revision, state: 'pending' as const}); +test('late startup snapshot cannot resurrect a request resolved in another window', () => { + const state = new Admissions(); + state.event(pending(1, 1)); state.event({...pending(1, 2), state: 'granted'}); + state.snapshot({revision: 1, pending: [pending(1, 1)]}); assert.deepEqual(state.requests, []); +}); +test('out-of-order events for different connections remain independent', () => { + const state = new Admissions(); state.event(pending(2, 2)); state.event(pending(1, 1)); + assert.deepEqual(state.requests.map(a => a.connId), [1, 2]); +}); +test('snapshot recovers missed events without overwriting newer changes', () => { + const state = new Admissions(); state.event(pending(1, 1)); state.event(pending(3, 5)); + state.snapshot({revision: 4, pending: [pending(2, 3)]}); assert.deepEqual(state.requests.map(a => a.connId), [2, 3]); + state.event(pending(1, 2)); assert.deepEqual(state.requests.map(a => a.connId), [2, 3]); +}); +test('closed IDs and same-name reconnects do not inherit admission', () => { + const state = new Admissions(); state.event({...pending(1, 3), state: 'closed'}); state.event(pending(2, 4)); + state.event(pending(1, 1)); assert.deepEqual(state.requests.map(a => a.connId), [2]); +}); +test('stale snapshots cannot erase a current request', () => { + const state = new Admissions(); state.snapshot({revision: 5, pending: [pending(1, 5)]}); state.snapshot({revision: 4, pending: []}); + assert.deepEqual(state.requests.map(a => a.connId), [1]); +}); + +import { Reconciler } from '../../src/lib/collaboration/reconcile.ts'; +test('a status read racing a live event is retried before applying', async () => { + let resolve!: (value: string) => void; let reads = 0; const applied: string[] = []; + const state = new Reconciler(() => ++reads === 1 ? new Promise(r => resolve = r) : Promise.resolve('new'), (_id, value) => applied.push(value)); + const pending = state.sync('one'); state.changed('one'); resolve('old'); await pending; + assert.deepEqual(applied, ['new']); assert.equal(reads, 2); +}); +test('parallel polls coalesce and unrelated sessions do not invalidate each other', async () => { + let resolve!: (value: string) => void; let reads = 0; const applied: string[] = []; + const state = new Reconciler(() => { reads++; return new Promise(r => resolve = r); }, (_id, value) => applied.push(value)); + const first=state.sync('one'), second=state.sync('one'); state.changed('two'); resolve('current'); await Promise.all([first,second]); + assert.equal(reads,1); assert.deepEqual(applied,['current']); +}); +test('failed reads preserve current state and a later retry can repair it', async () => { + let fail=true; const applied: string[]=[]; + const state=new Reconciler(async()=>{if(fail)throw Error('offline');return 'recovered';},(_id,value)=>applied.push(value)); + await assert.rejects(state.sync('one'));assert.deepEqual(applied,[]);fail=false;await state.sync('one');assert.deepEqual(applied,['recovered']); +}); +test('unmounted views do not apply late status responses', async () => { + let resolve!: (value: string) => void; const applied: string[]=[]; + const state=new Reconciler(()=>new Promise(r=>resolve=r),(_id,value)=>applied.push(value)); + const pending=state.sync('one');state.dispose();resolve('late');await pending;assert.deepEqual(applied,[]); +}); + +const snapshot = (): SessionSnapshot => ({ shared: true, externalOrigin: false, surfaceAvailable: true, inputPending: false, externalInputAvailable: false, processAlive: true, attended: true, profileId: 'shell', profileName: 'Shell', reviewRequired: false, mode: 'autopilot', effectiveMode: 'autopilot', controlGate: true, pacing: {minWriteIntervalMs:0, enterGraceMs:5000, leaseTtlSecs:60, approvalTtlSecs:300}, sessionAllows: [], affordanceMask: null, connectedAgents: ['one'], controller: {type:'human'}, attentionRequest: null, openedBy: null, lastAgent: null, pending: [], proposal: null, controlRequests: [] }); +function view() { + const t = { id:'one', title:'Shell', timeline: {recording:true}, controller:{type:'human'} } as TabState; + applySessionSnapshot(t,snapshot(),'Private'); return t; +} +test('event and snapshot use the same request projection without restarting its age', () => { + const t=view(); + const request={id:'review-1',agentId:'one',cmd:'echo fixture',label:'review'}; + applySessionEvent(t,{session:'one',event:'approval_requested',request}); + t.approval!.at=1234; + applySessionSnapshot(t,{...snapshot(),pending:[request]},'Private'); + assert.equal(t.approval!.at,1234); + applySessionEvent(t,{session:'two',event:'approval_resolved',approvalId:'review-1',state:'denied',by:'human'}); + assert.ok(t.approval, 'another session cannot resolve this card'); + applySessionEvent(t,{session:'one',event:'approval_resolved',approvalId:'review-1',state:'denied',by:'human'}); + assert.equal(t.approval,null); +}); +test('a missed grace event is recovered using the countdown clock and not reset by polling', () => { + const t=view();const s={...snapshot(),scheduled:{execId:'exec-1',cmd:'echo fixture'},scheduledRemainingMs:1200}; + const before=performance.now();applySessionSnapshot(t,s,'Private'); + assert.ok(t.grace!.start>=before && t.grace!.start<=performance.now());assert.equal(t.grace!.ms,1200); + const start=t.grace!.start; + applySessionSnapshot(t,s,'Private');assert.equal(t.grace!.start,start); + applySessionEvent(t,{session:'one',event:'exec_cancelled',execId:'older-exec',reason:'cancelled'});assert.ok(t.grace); + applySessionSnapshot(t,{...snapshot(),scheduled:null},'Private');assert.equal(t.grace,null); +}); +test('sharing revocation immediately clears collaboration and private snapshots cannot restore it', () => { + const t=view();t.handback=true; + applySessionEvent(t,{session:'one',event:'control_requested',request:{requestId:'control-1',agentId:'one'}}); + applySessionEvent(t,{session:'one',event:'sharing_changed',shared:false,generation:2}); + assert.equal(t.ctlReq,null);assert.equal(t.handback,false);assert.deepEqual(t.agents,[]);assert.equal(t.timeline.recording,false); + applySessionSnapshot(t,{...snapshot(),shared:false,controlRequests:[{requestId:'stale',agentId:'one'}]},'Private'); + applySessionEvent(t,{session:'one',event:'control_requested',request:{requestId:'late',agentId:'one'}}); + assert.equal(t.ctlReq,null);assert.deepEqual(t.agents,[]); +}); diff --git a/package-lock.json b/package-lock.json index 6b5da87..8636e04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ }, "frontends/tauri": { "name": "conn-desktop", - "version": "0.8.2", + "version": "0.8.3", "dependencies": { "@conn/brand": "*", "@conn/themes": "*", @@ -29,6 +29,7 @@ "@sveltejs/vite-plugin-svelte": "^5.1.1", "@tauri-apps/cli": "^2", "@tsconfig/svelte": "^5.0.8", + "playwright": "1.63.0", "svelte": "^5.57.0", "svelte-check": "^4.7.6", "typescript": "^5.6.0", @@ -409,6 +410,35 @@ "addons/*" ] }, + "frontends/tauri/node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "frontends/tauri/node_modules/playwright/node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "frontends/tauri/node_modules/svelte": { "version": "5.57.1", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.1.tgz", diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 57f0ad3..5fba44e 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "conn", "description": "Conn — one shell, one hand on it. Registers the conn MCP server and a skill that teaches the agent how to work in a shell a human is watching.", - "version": "0.8.2", + "version": "0.8.3", "author": { "name": "eggp" }, diff --git a/plugin/.codex-plugin/plugin.json b/plugin/.codex-plugin/plugin.json index a2dbc91..6370b15 100644 --- a/plugin/.codex-plugin/plugin.json +++ b/plugin/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "conn", - "version": "0.8.2", + "version": "0.8.3", "description": "Conn — one shell, one hand on it. Registers the conn MCP server and a skill that teaches the agent how to work in a shell a human is watching.", "author": { "name": "eggp" diff --git a/scripts/release_notes.md b/scripts/release_notes.md index 72a9448..5202943 100644 --- a/scripts/release_notes.md +++ b/scripts/release_notes.md @@ -52,16 +52,11 @@ is not claimed by artifact signature checks. ## Native validation and remaining coverage -The actual Apple Silicon development app passed the join prompt, reading and writing -while minimized, unfocused or on another tab, hidden/masked synthetic authentication, -AppleScript launch, same-SSH sharing, late external writes being refused and the -one-lease-per-connection rule. A Linux run against a real OpenSSH server found no -credential in snapshots, replies or saved data after sharing. See the -[macOS acceptance report]({repository}/blob/{tag}/docs/shared-surface-macos-validation-results.md). -Native WebKit completion fixtures passed; a real OpenAI request remains unverified. -The real-user provider path stays opt-in. Development-app checks are separate from -this release's CI signing/notarization and updater artifact-signature verification. -Windows interactive GUI and full installed-app upgrades remain unverified. +Validation coverage is recorded per version in the changelog and linked reports. +CI builds, scripting-dictionary checks, signing and notarization are separate from +interactive testing of an installed app. Previous development-app results do not +establish that every native interaction works in this build. Windows interactive +GUI and full installed-app upgrades remain unverified. AppleScript is disabled by default. Enable it for selected local profiles in Settings and consult the [automation guide]({repository}/blob/{tag}/docs/external-automation.md). @@ -109,7 +104,7 @@ not that the command completed successfully. Start with a disposable project. Read the [security model]({repository}/blob/{tag}/docs/security.md) and [report security issues privately]({repository}/security/advisories/new). -한국어: AppleScript는 기본적으로 꺼져 있습니다. 실제 Mac 개발 앱의 인증·공유·제어 전환을 검증했으며 배포 파일 서명·공증과는 구분합니다. Windows GUI와 모든 플랫폼의 실제 설치 상태 업그레이드, 실제 OpenAI 호출은 추가 검증 대상입니다. +한국어: AppleScript는 기본적으로 꺼져 있습니다. 버전별 검증 범위는 변경 기록과 연결된 보고서를 확인하세요. CI 빌드·스크립팅 사전·서명·공증 검증은 설치된 앱의 대화형 검증과 별개이며, 이전 개발 앱의 결과가 이번 배포판의 모든 동작을 보장하지 않습니다. 이 릴리스는 프리뷰입니다. 명령은 사용자 계정 권한으로 실행되며, 승인 기능은 운영체제 샌드박스가 아닙니다. 실행 기록은 입력 전달을 뜻하며 명령의 성공을 보장하지 않습니다. macOS 앱·내장 CLI·별도 CLI는 Developer ID로 서명하며 hardened runtime과