A safe, simple, extensible, and fast agent harness
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Rename `ein-server` to `eind`

+463 -93
+7 -7
.github/workflows/release.yml
··· 42 42 pull_request: 43 43 push: 44 44 tags: 45 - - '**[0-9]+.[0-9]+.[0-9]+*' 45 + - "**[0-9]+.[0-9]+.[0-9]+*" 46 46 47 47 jobs: 48 48 # Run 'dist plan' (or host) to determine what tasks we need to do ··· 218 218 name: artifacts-build-plugins 219 219 path: ${{ env.ARCHIVE }} 220 220 221 - # Build and package ein-server for each platform so bootstrap.rs can download it 221 + # Build and package eind for each platform so bootstrap.rs can download it 222 222 build-server-artifacts: 223 223 name: build-server-artifacts (${{ matrix.target }}) 224 224 needs: ··· 249 249 uses: arduino/setup-protoc@v3 250 250 with: 251 251 repo-token: ${{ secrets.GITHUB_TOKEN }} 252 - - name: Build ein-server 253 - run: cargo build --release -p ein-server --target ${{ matrix.target }} 252 + - name: Build eind 253 + run: cargo build --release -p eind --target ${{ matrix.target }} 254 254 - name: Package archive 255 255 shell: bash 256 256 run: | 257 257 TARGET="${{ matrix.target }}" 258 - ARCHIVE="ein-server-${TARGET}.tar.xz" 258 + ARCHIVE="eind-${TARGET}.tar.xz" 259 259 mkdir -p dist-server 260 - cp "target/${TARGET}/release/ein-server" dist-server/ 261 - tar -cJf "${ARCHIVE}" -C dist-server ein-server 260 + cp "target/${TARGET}/release/eind" dist-server/ 261 + tar -cJf "${ARCHIVE}" -C dist-server eind 262 262 echo "ARCHIVE=${ARCHIVE}" >> "$GITHUB_ENV" 263 263 - name: Upload server archive artifact 264 264 uses: actions/upload-artifact@v6
+5 -5
CLAUDE.md
··· 12 12 rustup target add wasm32-wasip2 13 13 cargo build # Build all crates 14 14 cargo build -p ein-tui # Build just the TUI client 15 - cargo build -p ein-server # Build just the server 15 + cargo build -p eind # Build just the server 16 16 ``` 17 17 18 18 Plugins (tool plugins and model client plugins) are WASM components compiled separately: ··· 42 42 43 43 ```bash 44 44 # Terminal 1 — start the server (no env vars needed) 45 - cargo run --bin ein-server 45 + cargo run --bin eind 46 46 47 47 # Terminal 2 — start the TUI (connects to localhost:50051 by default) 48 48 cargo run --bin ein-tui ··· 59 59 60 60 ``` 61 61 ┌─────────────────────────────┐ ┌──────────────────────────────┐ 62 - │ ein-tui │ gRPC │ ein-server │ 62 + │ ein-tui │ gRPC │ eind │ 63 63 │ │ (proto) │ │ 64 64 │ Ratatui terminal UI │◄────────►│ Agent loop + tool executor │ 65 65 │ Keyboard / render loop │ │ WASM plugin host │ ··· 103 103 - **Save** — after each agent turn, the full message history is serialised and written (`save_messages`). 104 104 - **Resume** — when a client supplies a known `session_id`, `load_messages` restores the conversation so the agent picks up where it left off. 105 105 106 - Database migrations live in `crates/ein-server/migrations/`. 106 + Database migrations live in `eind/migrations/`. 107 107 108 108 ### Client config (`crates/ein-tui/src/config.rs`) 109 109 ··· 113 113 114 114 Legacy flat config files (with top-level `api_key`, `base_url`, `model`, `max_tokens`) are automatically migrated to the nested format on load. 115 115 116 - ### Server (`crates/ein-server/`) 116 + ### Server (`eind/`) 117 117 118 118 | File | Role | 119 119 |------|------|
+28 -28
Cargo.lock
··· 1003 1003 ] 1004 1004 1005 1005 [[package]] 1006 - name = "ein-server" 1007 - version = "0.1.8" 1008 - dependencies = [ 1009 - "anyhow", 1010 - "async-trait", 1011 - "clap", 1012 - "dirs", 1013 - "ein-agent", 1014 - "ein-proto", 1015 - "ein_plugin", 1016 - "futures", 1017 - "hyper", 1018 - "reqwest", 1019 - "serde", 1020 - "serde_json", 1021 - "sqlx", 1022 - "tar", 1023 - "tokio", 1024 - "tokio-stream", 1025 - "tonic", 1026 - "uuid", 1027 - "wasmtime", 1028 - "wasmtime-wasi", 1029 - "wasmtime-wasi-http", 1030 - "xz2", 1031 - ] 1032 - 1033 - [[package]] 1034 1006 name = "ein-tui" 1035 1007 version = "0.1.8" 1036 1008 dependencies = [ ··· 1167 1139 "serde_json", 1168 1140 "tempfile", 1169 1141 "wit-bindgen 0.53.1", 1142 + ] 1143 + 1144 + [[package]] 1145 + name = "eind" 1146 + version = "0.1.8" 1147 + dependencies = [ 1148 + "anyhow", 1149 + "async-trait", 1150 + "clap", 1151 + "dirs", 1152 + "ein-agent", 1153 + "ein-proto", 1154 + "ein_plugin", 1155 + "futures", 1156 + "hyper", 1157 + "reqwest", 1158 + "serde", 1159 + "serde_json", 1160 + "sqlx", 1161 + "tar", 1162 + "tokio", 1163 + "tokio-stream", 1164 + "tonic", 1165 + "uuid", 1166 + "wasmtime", 1167 + "wasmtime-wasi", 1168 + "wasmtime-wasi-http", 1169 + "xz2", 1170 1170 ] 1171 1171 1172 1172 [[package]]
+2 -2
Cargo.toml
··· 1 1 [workspace] 2 2 members = [ 3 - "crates/ein-server", 3 + "eind", 4 4 "crates/ein-tui", 5 5 "crates/ein-proto", 6 6 "crates/ein-agent", ··· 8 8 "packages/*", 9 9 ] 10 10 default-members = [ 11 - "crates/ein-server", 11 + "eind", 12 12 "crates/ein-tui", 13 13 "crates/ein-proto", 14 14 "crates/ein-agent",
+7 -7
README.md
··· 4 4 5 5 ``` 6 6 ┌─────────────────────────────┐ ┌──────────────────────────────┐ 7 - │ ein-tui │ gRPC │ ein-server │ 7 + │ ein-tui │ gRPC │ eind │ 8 8 │ │ (proto) │ │ 9 9 │ Ratatui terminal UI │◄────────►│ Agent loop + tool executor │ 10 10 │ Session picker on startup │ │ WASM plugin host │ ··· 23 23 cargo binstall --git https://github.com/mstallmo/ein ein 24 24 ``` 25 25 26 - This installs both `ein-tui` (terminal UI) and `ein-server` (gRPC agent server). You can also install them individually: 26 + This installs both `ein-tui` (terminal UI) and `eind` (gRPC agent server). You can also install them individually: 27 27 28 28 ```bash 29 29 cargo binstall --git https://github.com/mstallmo/ein ein-tui 30 - cargo binstall --git https://github.com/mstallmo/ein ein-server 30 + cargo binstall --git https://github.com/mstallmo/ein eind 31 31 ``` 32 32 33 33 Or download archives directly from [GitHub Releases](https://github.com/mstallmo/ein/releases). ··· 128 128 Start the server in one terminal: 129 129 130 130 ```bash 131 - cargo run --bin ein-server 131 + cargo run --bin eind 132 132 ``` 133 133 134 134 Start the TUI client in another: ··· 270 270 ``` 271 271 crates/ 272 272 ein-proto/ Protocol Buffer definitions (gRPC service + message types) 273 - ein-server/ gRPC server — agent loop, WASM plugin host, session persistence 273 + eind/ gRPC server — agent loop, WASM plugin host, session persistence 274 274 ein-tui/ Terminal UI client 275 275 packages/ 276 276 ein_tool/ WASM tool plugin interface (ToolPlugin trait, ToolDef, syscalls) ··· 315 315 316 316 Uses **Ratatui** (v0.29) for rendering and **crossterm** for keyboard events. The conversation pane renders a corgi pixel-art header on startup. Edit diffs are syntax-highlighted using `syntect` with the `base16-ocean.dark` theme. 317 317 318 - ### Server modules (`crates/ein-server/src/`) 318 + ### Server modules (`eind/src/`) 319 319 320 320 | File | Role | 321 321 |------|------| ··· 329 329 330 330 ## Releasing 331 331 332 - Releases are fully automated via CI using [cargo-dist](https://axodotdev.github.io/cargo-dist/). Only the `crates/ein` meta-package is distributed — it includes both the `ein-tui` and `ein-server` binaries. 332 + Releases are fully automated via CI using [cargo-dist](https://axodotdev.github.io/cargo-dist/). Only the `crates/ein` meta-package is distributed — it includes both the `ein-tui` and `eind` binaries. 333 333 334 334 **1. Bump the version** 335 335
+6 -6
crates/ein-server/Cargo.toml eind/Cargo.toml
··· 1 1 [package] 2 - name = "ein-server" 2 + name = "eind" 3 3 version.workspace = true 4 4 edition.workspace = true 5 5 authors.workspace = true ··· 8 8 homepage.workspace = true 9 9 10 10 [lib] 11 - name = "ein_server" 11 + name = "eind" 12 12 path = "src/lib.rs" 13 13 14 14 [[bin]] 15 - name = "ein-server" 15 + name = "eind" 16 16 path = "src/main.rs" 17 17 18 18 ··· 21 21 async-trait = "0.1" 22 22 clap = { version = "4.3.14", features = ["derive"] } 23 23 dirs = "6.0.0" 24 - ein-agent = { path = "../ein-agent" } 25 - ein-proto = { path = "../ein-proto" } 26 - ein_plugin = { path = "../../packages/ein_plugin" } 24 + ein-agent = { path = "../crates/ein-agent" } 25 + ein-proto = { path = "../crates/ein-proto" } 26 + ein_plugin = { path = "../packages/ein_plugin" } 27 27 futures = { workspace = true } 28 28 serde = { workspace = true } 29 29 serde_json = { workspace = true }
crates/ein-server/migrations/20260408224258_create_sessions.sql eind/migrations/20260408224258_create_sessions.sql
crates/ein-server/src/grpc.rs eind/src/grpc.rs
+2 -2
crates/ein-server/src/lib.rs eind/src/lib.rs
··· 3 3 4 4 //! Ein server library. 5 5 //! 6 - //! Exposes [`run`] so both the standalone `ein-server` binary and the `ein` 6 + //! Exposes [`run`] so both the standalone `eind` binary and the `ein` 7 7 //! meta-package binary can share the same entry-point without duplicating code. 8 8 9 9 mod grpc; ··· 72 72 73 73 let server = AgentServer::new().await?; 74 74 75 - println!("ein-server listening on {addr}"); 75 + println!("eind listening on {addr}"); 76 76 77 77 Server::builder() 78 78 .add_service(AgentServiceServer::new(server))
+2 -2
crates/ein-server/src/main.rs eind/src/main.rs
··· 29 29 let args = Args::parse(); 30 30 31 31 match args.command { 32 - Some(Commands::InstallPlugins { version }) => ein_server::install_plugins(version).await, 33 - None => ein_server::run(args.port).await, 32 + Some(Commands::InstallPlugins { version }) => eind::install_plugins(version).await, 33 + None => eind::run(args.port).await, 34 34 } 35 35 }
crates/ein-server/src/model_client.rs eind/src/model_client.rs
+1 -1
crates/ein-server/src/model_client/bindings.rs eind/src/model_client/bindings.rs
··· 5 5 6 6 bindgen!({ 7 7 world: "model-client", 8 - path: "../../wit/model_client", 8 + path: "../wit/model_client", 9 9 imports: { default: async }, 10 10 exports: { default: async } 11 11 });
crates/ein-server/src/model_client/syscalls.rs eind/src/model_client/syscalls.rs
crates/ein-server/src/persistence.rs eind/src/persistence.rs
crates/ein-server/src/plugins.rs eind/src/plugins.rs
+1 -1
crates/ein-server/src/tools.rs eind/src/tools.rs
··· 242 242 243 243 // Mount each allowed path at its absolute guest path so plugins can 244 244 // open files by absolute path. Additionally mount the first path as 245 - // "." so that relative paths (e.g. "crates/ein-server/Cargo.toml") 245 + // "." so that relative paths (e.g. "crates/eind/Cargo.toml") 246 246 // resolve correctly — WASI resolves relative paths against the guest 247 247 // current directory, which must be explicitly preopened. 248 248 let mut first = true;
+1 -1
crates/ein-server/src/tools/bindings.rs eind/src/tools/bindings.rs
··· 5 5 6 6 bindgen!({ 7 7 world: "plugin", 8 - path: "../../wit/plugin", 8 + path: "../wit/plugin", 9 9 imports: { default: async }, 10 10 exports: { default: async } 11 11 });
crates/ein-server/src/tools/syscalls.rs eind/src/tools/syscalls.rs
+18 -18
crates/ein-tui/src/bootstrap.rs
··· 1 1 // SPDX-License-Identifier: Apache-2.0 2 2 // Copyright 2026 Mason Stallmo 3 3 4 - //! Bootstrap logic: downloads `ein-server` on first run and registers it as a 4 + //! Bootstrap logic: downloads `eind` on first run and registers it as a 5 5 //! system service (macOS LaunchAgent or Linux systemd user service). 6 6 7 7 // These items are only called from the #[cfg(not(debug_assertions))] block in ··· 14 14 os::unix::fs::PermissionsExt, 15 15 path::{Path, PathBuf}, 16 16 }; 17 - use xz2::read::XzDecoder; 18 17 use tar::Archive; 19 18 use tokio::{fs, process::Command, task}; 19 + use xz2::read::XzDecoder; 20 20 21 21 const GITHUB_REPO: &str = "mstallmo/ein"; 22 22 23 - /// Path where `ein` installs the server binary: `~/.ein/bin/ein-server`. 23 + /// Path where `ein` installs the server binary: `~/.ein/bin/eind`. 24 24 pub fn server_bin_path() -> PathBuf { 25 25 dirs::home_dir() 26 26 .expect("home directory not found") 27 27 .join(".ein") 28 28 .join("bin") 29 - .join("ein-server") 29 + .join("eind") 30 30 } 31 31 32 32 /// Compile-time target triple used to select the right GitHub release asset. ··· 43 43 "" 44 44 } 45 45 46 - /// Downloads the `ein-server` binary for the current platform from GitHub 47 - /// releases and writes it to `~/.ein/bin/ein-server` with executable permissions. 46 + /// Downloads the `eind` binary for the current platform from GitHub 47 + /// releases and writes it to `~/.ein/bin/eind` with executable permissions. 48 48 pub async fn download_server(version: &str) -> Result<()> { 49 49 let ver = version.trim_start_matches('v'); 50 50 let tag = format!("v{ver}"); 51 51 let triple = target_triple(); 52 52 // cargo-dist names archives as "{package}-{triple}.tar.xz" (no version in filename). 53 - let archive_name = format!("ein-server-{triple}.tar.xz"); 53 + let archive_name = format!("eind-{triple}.tar.xz"); 54 54 let url = format!("https://github.com/{GITHUB_REPO}/releases/download/{tag}/{archive_name}"); 55 55 56 56 let dest = server_bin_path(); ··· 83 83 perms.set_mode(0o755); 84 84 fs::set_permissions(&dest, perms).await?; 85 85 86 - println!("ein-server installed to {}", dest.display()); 86 + println!("eind installed to {}", dest.display()); 87 87 Ok(()) 88 88 } 89 89 90 - /// Extracts the `ein-server` binary from a tar.xz archive into `dest`. 90 + /// Extracts the `eind` binary from a tar.xz archive into `dest`. 91 91 fn extract_server(bytes: &[u8], dest: &Path) -> Result<()> { 92 92 let xz = XzDecoder::new(io::Cursor::new(bytes)); 93 93 let mut archive = Archive::new(xz); ··· 99 99 let mut entry = entry.context("corrupt archive entry")?; 100 100 let entry_path = entry.path().context("entry has no path")?; 101 101 102 - // The archive contains exactly one file: the `ein-server` binary. 102 + // The archive contains exactly one file: the `eind` binary. 103 103 // Accept it regardless of any leading directory component. 104 104 let file_name = entry_path 105 105 .file_name() 106 106 .and_then(|n| n.to_str()) 107 107 .unwrap_or(""); 108 108 109 - if file_name == "ein-server" { 109 + if file_name == "eind" { 110 110 let mut file = std::fs::File::create(dest) 111 111 .with_context(|| format!("failed to create {}", dest.display()))?; 112 - io::copy(&mut entry, &mut file).context("failed to write ein-server")?; 112 + io::copy(&mut entry, &mut file).context("failed to write eind")?; 113 113 return Ok(()); 114 114 } 115 115 } 116 116 117 - anyhow::bail!("ein-server binary not found in archive") 117 + anyhow::bail!("eind binary not found in archive") 118 118 } 119 119 120 120 // --------------------------------------------------------------------------- 121 121 // Service registration 122 122 // --------------------------------------------------------------------------- 123 123 124 - /// Ensures `ein-server` is registered as a system service. 124 + /// Ensures `eind` is registered as a system service. 125 125 /// 126 126 /// On macOS, installs a LaunchAgent plist and loads it. 127 127 /// On Linux, writes a systemd user unit and enables it. ··· 141 141 // Uninstall 142 142 // --------------------------------------------------------------------------- 143 143 144 - /// Stops and removes the `ein-server` service and binary installed by 144 + /// Stops and removes the `eind` service and binary installed by 145 145 /// [`ensure_service_installed`] and [`download_server`]. 146 146 /// 147 147 /// Returns a list of completed step descriptions for display in the TUI. ··· 292 292 anyhow::bail!("launchctl load failed: {stderr}"); 293 293 } 294 294 295 - println!("ein-server registered as LaunchAgent ({LAUNCH_AGENT_LABEL})"); 295 + println!("eind registered as LaunchAgent ({LAUNCH_AGENT_LABEL})"); 296 296 Ok(()) 297 297 } 298 298 ··· 301 301 // --------------------------------------------------------------------------- 302 302 303 303 #[cfg(target_os = "linux")] 304 - const SYSTEMD_SERVICE_NAME: &str = "ein-server"; 304 + const SYSTEMD_SERVICE_NAME: &str = "eind"; 305 305 306 306 #[cfg(target_os = "linux")] 307 307 fn systemd_unit_path() -> PathBuf { ··· 363 363 anyhow::bail!("systemctl enable failed: {stderr}"); 364 364 } 365 365 366 - println!("ein-server registered as systemd user service ({SYSTEMD_SERVICE_NAME})"); 366 + println!("eind registered as systemd user service ({SYSTEMD_SERVICE_NAME})"); 367 367 Ok(()) 368 368 }
+3 -3
crates/ein-tui/src/input.rs
··· 6 6 use tracing::{debug, info, warn}; 7 7 8 8 use crate::app::{ 9 - App, CwdState, DisplayMessage, Modal, SessionPickerState, SetupWizardState, UninstallModalState, 10 - UninstallPhase, WizardStep, 9 + App, CwdState, DisplayMessage, Modal, SessionPickerState, SetupWizardState, 10 + UninstallModalState, UninstallPhase, WizardStep, 11 11 }; 12 12 use crate::connection::to_proto_session_config; 13 13 ··· 59 59 }, 60 60 CommandDef { 61 61 name: "/uninstall", 62 - description: "Stop and remove the ein-server service and binary", 62 + description: "Stop and remove the eind service and binary", 63 63 }, 64 64 ]; 65 65
+2 -2
crates/ein-tui/src/lib.rs
··· 98 98 99 99 info!(server_addr = %args.server_addr, "ein-tui starting"); 100 100 101 - // In release builds: download ein-server if absent, then register it as a 101 + // In release builds: download eind if absent, then register it as a 102 102 // system service. Runs before raw mode so stdout is visible for progress. 103 103 #[cfg(not(debug_assertions))] 104 104 { 105 105 let bin = bootstrap::server_bin_path(); 106 106 if !bin.exists() { 107 - println!("Downloading ein-server {}...", env!("CARGO_PKG_VERSION")); 107 + println!("Downloading eind {}...", env!("CARGO_PKG_VERSION")); 108 108 bootstrap::download_server(env!("CARGO_PKG_VERSION")).await?; 109 109 } 110 110 bootstrap::ensure_service_installed().await?;
+4 -2
crates/ein-tui/src/render.rs
··· 766 766 UninstallPhase::Done { .. } => state.log.len().max(1) as u16 + 2, 767 767 }; 768 768 let modal_height = 2 + 1 + content_lines; 769 - let modal_width = (frame.area().width * 7 / 10).max(60).min(frame.area().width); 769 + let modal_width = (frame.area().width * 7 / 10) 770 + .max(60) 771 + .min(frame.area().width); 770 772 let area = centered_rect(modal_width, modal_height, frame.area()); 771 773 772 774 frame.render_widget(Clear, area); 773 775 774 776 let (title, border_color) = match &state.phase { 775 - UninstallPhase::Confirm => (" Uninstall ein-server? ", DISCONNECTED_COLOR), 777 + UninstallPhase::Confirm => (" Uninstall eind? ", DISCONNECTED_COLOR), 776 778 UninstallPhase::Running => (" Uninstalling… ", MUTED_COLOR), 777 779 UninstallPhase::Done { success: true } => (" Uninstalled ", Color::Green), 778 780 UninstallPhase::Done { success: false } => (" Uninstall failed ", DISCONNECTED_COLOR),
+368
ein/src/bootstrap.rs
··· 1 + // SPDX-License-Identifier: Apache-2.0 2 + // Copyright 2026 Mason Stallmo 3 + 4 + //! Bootstrap logic: downloads `eind` on first run and registers it as a 5 + //! system service (macOS LaunchAgent or Linux systemd user service). 6 + 7 + // These items are only called from the #[cfg(not(debug_assertions))] block in 8 + // lib.rs, so they appear unused in debug builds. That's intentional. 9 + #![cfg_attr(debug_assertions, allow(dead_code))] 10 + 11 + use anyhow::{Context, Result}; 12 + use std::{ 13 + io, 14 + os::unix::fs::PermissionsExt, 15 + path::{Path, PathBuf}, 16 + }; 17 + use tar::Archive; 18 + use tokio::{fs, process::Command, task}; 19 + use xz2::read::XzDecoder; 20 + 21 + const GITHUB_REPO: &str = "mstallmo/ein"; 22 + 23 + /// Path where `ein` installs the server binary: `~/.ein/bin/eind`. 24 + pub fn server_bin_path() -> PathBuf { 25 + dirs::home_dir() 26 + .expect("home directory not found") 27 + .join(".ein") 28 + .join("bin") 29 + .join("eind") 30 + } 31 + 32 + /// Compile-time target triple used to select the right GitHub release asset. 33 + pub fn target_triple() -> &'static str { 34 + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] 35 + return "aarch64-apple-darwin"; 36 + #[cfg(all(target_os = "macos", target_arch = "x86_64"))] 37 + return "x86_64-apple-darwin"; 38 + #[cfg(all(target_os = "linux", target_arch = "aarch64"))] 39 + return "aarch64-unknown-linux-gnu"; 40 + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] 41 + return "x86_64-unknown-linux-gnu"; 42 + #[allow(unreachable_code)] 43 + "" 44 + } 45 + 46 + /// Downloads the `eind` binary for the current platform from GitHub 47 + /// releases and writes it to `~/.ein/bin/eind` with executable permissions. 48 + pub async fn download_server(version: &str) -> Result<()> { 49 + let ver = version.trim_start_matches('v'); 50 + let tag = format!("v{ver}"); 51 + let triple = target_triple(); 52 + // cargo-dist names archives as "{package}-{triple}.tar.xz" (no version in filename). 53 + let archive_name = format!("eind-{triple}.tar.xz"); 54 + let url = format!("https://github.com/{GITHUB_REPO}/releases/download/{tag}/{archive_name}"); 55 + 56 + let dest = server_bin_path(); 57 + fs::create_dir_all(dest.parent().unwrap()) 58 + .await 59 + .context("failed to create ~/.ein/bin")?; 60 + 61 + println!("Downloading {url}..."); 62 + 63 + let response = reqwest::get(&url) 64 + .await 65 + .with_context(|| format!("failed to fetch {url}"))?; 66 + 67 + if !response.status().is_success() { 68 + anyhow::bail!("download failed: HTTP {}", response.status()); 69 + } 70 + 71 + let bytes = response 72 + .bytes() 73 + .await 74 + .context("failed to read response body")?; 75 + 76 + let dest_clone = dest.clone(); 77 + task::spawn_blocking(move || extract_server(&bytes, &dest_clone)) 78 + .await 79 + .context("extraction task panicked")??; 80 + 81 + // Make the binary executable. 82 + let mut perms = fs::metadata(&dest).await?.permissions(); 83 + perms.set_mode(0o755); 84 + fs::set_permissions(&dest, perms).await?; 85 + 86 + println!("eind installed to {}", dest.display()); 87 + Ok(()) 88 + } 89 + 90 + /// Extracts the `eind` binary from a tar.xz archive into `dest`. 91 + fn extract_server(bytes: &[u8], dest: &Path) -> Result<()> { 92 + let xz = XzDecoder::new(io::Cursor::new(bytes)); 93 + let mut archive = Archive::new(xz); 94 + 95 + for entry in archive 96 + .entries() 97 + .context("failed to read archive entries")? 98 + { 99 + let mut entry = entry.context("corrupt archive entry")?; 100 + let entry_path = entry.path().context("entry has no path")?; 101 + 102 + // The archive contains exactly one file: the `eind` binary. 103 + // Accept it regardless of any leading directory component. 104 + let file_name = entry_path 105 + .file_name() 106 + .and_then(|n| n.to_str()) 107 + .unwrap_or(""); 108 + 109 + if file_name == "eind" { 110 + let mut file = std::fs::File::create(dest) 111 + .with_context(|| format!("failed to create {}", dest.display()))?; 112 + io::copy(&mut entry, &mut file).context("failed to write eind")?; 113 + return Ok(()); 114 + } 115 + } 116 + 117 + anyhow::bail!("eind binary not found in archive") 118 + } 119 + 120 + // --------------------------------------------------------------------------- 121 + // Service registration 122 + // --------------------------------------------------------------------------- 123 + 124 + /// Ensures `eind` is registered as a system service. 125 + /// 126 + /// On macOS, installs a LaunchAgent plist and loads it. 127 + /// On Linux, writes a systemd user unit and enables it. 128 + /// On other platforms, does nothing (the TUI's retry loop handles reconnects). 129 + pub async fn ensure_service_installed() -> Result<()> { 130 + #[cfg(target_os = "macos")] 131 + return ensure_launchagent_installed().await; 132 + 133 + #[cfg(target_os = "linux")] 134 + return ensure_systemd_installed().await; 135 + 136 + #[cfg(not(any(target_os = "macos", target_os = "linux")))] 137 + Ok(()) 138 + } 139 + 140 + // --------------------------------------------------------------------------- 141 + // Uninstall 142 + // --------------------------------------------------------------------------- 143 + 144 + /// Stops and removes the `eind` service and binary installed by 145 + /// [`ensure_service_installed`] and [`download_server`]. 146 + /// 147 + /// Returns a list of completed step descriptions for display in the TUI. 148 + /// User config and session data in `~/.ein/` are left intact. 149 + pub async fn uninstall() -> Result<Vec<String>> { 150 + let mut steps: Vec<String> = Vec::new(); 151 + #[cfg(target_os = "macos")] 152 + uninstall_launchagent(&mut steps).await?; 153 + #[cfg(target_os = "linux")] 154 + uninstall_systemd(&mut steps).await?; 155 + remove_server_binary(&mut steps).await?; 156 + Ok(steps) 157 + } 158 + 159 + async fn remove_server_binary(steps: &mut Vec<String>) -> Result<()> { 160 + let path = server_bin_path(); 161 + if path.exists() { 162 + fs::remove_file(&path) 163 + .await 164 + .with_context(|| format!("failed to remove {}", path.display()))?; 165 + steps.push(format!("Removed {}", path.display())); 166 + } 167 + Ok(()) 168 + } 169 + 170 + #[cfg(target_os = "macos")] 171 + async fn uninstall_launchagent(steps: &mut Vec<String>) -> Result<()> { 172 + let plist = launchagent_plist_path(); 173 + // Ignore errors — the service may already be stopped/unloaded. 174 + let _ = Command::new("launchctl") 175 + .args(["unload", plist.to_str().unwrap_or("")]) 176 + .output() 177 + .await; 178 + if plist.exists() { 179 + fs::remove_file(&plist) 180 + .await 181 + .with_context(|| format!("failed to remove {}", plist.display()))?; 182 + steps.push(format!("Removed LaunchAgent ({})", LAUNCH_AGENT_LABEL)); 183 + } 184 + Ok(()) 185 + } 186 + 187 + #[cfg(target_os = "linux")] 188 + async fn uninstall_systemd(steps: &mut Vec<String>) -> Result<()> { 189 + let unit = systemd_unit_path(); 190 + let _ = Command::new("systemctl") 191 + .args(["--user", "stop", SYSTEMD_SERVICE_NAME]) 192 + .output() 193 + .await; 194 + let _ = Command::new("systemctl") 195 + .args(["--user", "disable", SYSTEMD_SERVICE_NAME]) 196 + .output() 197 + .await; 198 + if unit.exists() { 199 + fs::remove_file(&unit) 200 + .await 201 + .with_context(|| format!("failed to remove {}", unit.display()))?; 202 + steps.push(format!( 203 + "Removed systemd user service ({})", 204 + SYSTEMD_SERVICE_NAME 205 + )); 206 + } 207 + let _ = Command::new("systemctl") 208 + .args(["--user", "daemon-reload"]) 209 + .output() 210 + .await; 211 + Ok(()) 212 + } 213 + 214 + // --------------------------------------------------------------------------- 215 + // macOS LaunchAgent 216 + // --------------------------------------------------------------------------- 217 + 218 + #[cfg(target_os = "macos")] 219 + const LAUNCH_AGENT_LABEL: &str = "com.ein.eind"; 220 + 221 + #[cfg(target_os = "macos")] 222 + fn launchagent_plist_path() -> PathBuf { 223 + dirs::home_dir() 224 + .expect("home directory not found") 225 + .join("Library") 226 + .join("LaunchAgents") 227 + .join(format!("{LAUNCH_AGENT_LABEL}.plist")) 228 + } 229 + 230 + #[cfg(target_os = "macos")] 231 + async fn ensure_launchagent_installed() -> Result<()> { 232 + // Check if already loaded. 233 + let status = Command::new("launchctl") 234 + .args(["list", LAUNCH_AGENT_LABEL]) 235 + .output() 236 + .await 237 + .context("launchctl not found")?; 238 + 239 + if status.status.success() { 240 + return Ok(()); // Already running. 241 + } 242 + 243 + let plist_path = launchagent_plist_path(); 244 + let bin = server_bin_path(); 245 + let log = dirs::home_dir() 246 + .expect("home directory not found") 247 + .join(".ein") 248 + .join("server.log"); 249 + 250 + let plist = format!( 251 + r#"<?xml version="1.0" encoding="UTF-8"?> 252 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 253 + <plist version="1.0"> 254 + <dict> 255 + <key>Label</key> 256 + <string>{LAUNCH_AGENT_LABEL}</string> 257 + <key>ProgramArguments</key> 258 + <array> 259 + <string>{bin}</string> 260 + </array> 261 + <key>RunAtLoad</key> 262 + <true/> 263 + <key>KeepAlive</key> 264 + <true/> 265 + <key>StandardOutPath</key> 266 + <string>{log}</string> 267 + <key>StandardErrorPath</key> 268 + <string>{log}</string> 269 + </dict> 270 + </plist> 271 + "#, 272 + LAUNCH_AGENT_LABEL = LAUNCH_AGENT_LABEL, 273 + bin = bin.display(), 274 + log = log.display(), 275 + ); 276 + 277 + fs::create_dir_all(plist_path.parent().unwrap()) 278 + .await 279 + .context("failed to create LaunchAgents directory")?; 280 + fs::write(&plist_path, plist) 281 + .await 282 + .context("failed to write plist")?; 283 + 284 + let output = Command::new("launchctl") 285 + .args(["load", plist_path.to_str().unwrap()]) 286 + .output() 287 + .await 288 + .context("failed to run launchctl load")?; 289 + 290 + if !output.status.success() { 291 + let stderr = String::from_utf8_lossy(&output.stderr); 292 + anyhow::bail!("launchctl load failed: {stderr}"); 293 + } 294 + 295 + println!("eind registered as LaunchAgent ({LAUNCH_AGENT_LABEL})"); 296 + Ok(()) 297 + } 298 + 299 + // --------------------------------------------------------------------------- 300 + // Linux systemd user service 301 + // --------------------------------------------------------------------------- 302 + 303 + #[cfg(target_os = "linux")] 304 + const SYSTEMD_SERVICE_NAME: &str = "eind"; 305 + 306 + #[cfg(target_os = "linux")] 307 + fn systemd_unit_path() -> PathBuf { 308 + dirs::home_dir() 309 + .expect("home directory not found") 310 + .join(".config") 311 + .join("systemd") 312 + .join("user") 313 + .join(format!("{SYSTEMD_SERVICE_NAME}.service")) 314 + } 315 + 316 + #[cfg(target_os = "linux")] 317 + async fn ensure_systemd_installed() -> Result<()> { 318 + // Check if already enabled. 319 + let status = Command::new("systemctl") 320 + .args(["--user", "is-enabled", SYSTEMD_SERVICE_NAME]) 321 + .output() 322 + .await 323 + .context("systemctl not found")?; 324 + 325 + if status.status.success() { 326 + return Ok(()); // Already enabled. 327 + } 328 + 329 + let unit_path = systemd_unit_path(); 330 + let bin = server_bin_path(); 331 + 332 + let unit = format!( 333 + "[Unit]\nDescription=Ein server\n\n[Service]\nExecStart={bin}\nRestart=always\n\n[Install]\nWantedBy=default.target\n", 334 + bin = bin.display(), 335 + ); 336 + 337 + fs::create_dir_all(unit_path.parent().unwrap()) 338 + .await 339 + .context("failed to create systemd user directory")?; 340 + fs::write(&unit_path, unit) 341 + .await 342 + .context("failed to write systemd unit")?; 343 + 344 + let reload = Command::new("systemctl") 345 + .args(["--user", "daemon-reload"]) 346 + .output() 347 + .await 348 + .context("failed to run systemctl daemon-reload")?; 349 + 350 + if !reload.status.success() { 351 + let stderr = String::from_utf8_lossy(&reload.stderr); 352 + anyhow::bail!("systemctl daemon-reload failed: {stderr}"); 353 + } 354 + 355 + let enable = Command::new("systemctl") 356 + .args(["--user", "enable", "--now", SYSTEMD_SERVICE_NAME]) 357 + .output() 358 + .await 359 + .context("failed to run systemctl enable")?; 360 + 361 + if !enable.status.success() { 362 + let stderr = String::from_utf8_lossy(&enable.stderr); 363 + anyhow::bail!("systemctl enable failed: {stderr}"); 364 + } 365 + 366 + println!("eind registered as systemd user service ({SYSTEMD_SERVICE_NAME})"); 367 + Ok(()) 368 + }
+3 -3
specs/EIN-SERVER-CODE-REVIEW-v1.md
··· 1 - # ein-server Code Review Report 1 + # eind Code Review Report 2 2 3 3 _Evaluation Date: 2026-04-03_ 4 4 ··· 6 6 7 7 ## Executive Summary 8 8 9 - The `ein-server` (located in `crates/ein-server`) is a sophisticated gRPC server that implements a secure WASM-based agent hosting model. It successfully implements the core architecture of: 9 + The `eind` (located in `eind`) is a sophisticated gRPC server that implements a secure WASM-based agent hosting model. It successfully implements the core architecture of: 10 10 - Loading and orchestrating WASM WASI plugins with **per-session isolation** 11 11 - Fine-grained **access controls** (filesystem paths, network hosts) 12 12 - Graceful **error handling** (preserving sessions after errors) ··· 807 807 808 808 ## Conclusion 809 809 810 - **The `ein-server` is a well-architected and innovative implementation** of a secure WASM-based agent hosting model. Its design prioritizes: 810 + **The `eind` is a well-architected and innovative implementation** of a secure WASM-based agent hosting model. Its design prioritizes: 811 811 - ✅ Session isolation 812 812 - ✅ Fine-grained access control 813 813 - ✅ Graceful error handling
+3 -3
specs/EIN-SERVER-CODE-REVIEW-v2.md
··· 1 - # ein-server Code Review Report 1 + # eind Code Review Report 2 2 3 3 _Evaluation Date: 2026-04-04_ 4 4 _Rated: v0.1.0_ ··· 7 7 8 8 ## Executive Summary 9 9 10 - The `ein-server` (located in `crates/ein-server`) is a sophisticated gRPC server that implements a secure WASM-based agent hosting model. It successfully implements the core architecture of: 10 + The `eind` (located in `eind`) is a sophisticated gRPC server that implements a secure WASM-based agent hosting model. It successfully implements the core architecture of: 11 11 12 12 - Loading and orchestrating WASM WASI plugins with **per-session isolation** 13 13 - Fine-grained **access controls** (filesystem paths, network hosts) ··· 461 461 462 462 ## Conclusion 463 463 464 - The `ein-server` is well-architected for Wasm plugin execution, with a clear separation of concerns. Security concerns around shell command execution must be addressed first. Error handling and metric instrumentation would significantly improve user experience and debugging. With the fixes above, `ein-server` will be a robust, observable, and secure component of the `ein` ecosystem. 464 + The `eind` is well-architected for Wasm plugin execution, with a clear separation of concerns. Security concerns around shell command execution must be addressed first. Error handling and metric instrumentation would significantly improve user experience and debugging. With the fixes above, `eind` will be a robust, observable, and secure component of the `ein` ecosystem. 465 465 466 466 --- 467 467