From 36503fd005306df50a548df3e95f3d4b7d26bc1c Mon Sep 17 00:00:00 2001 From: Louis Thibault Date: Wed, 20 May 2026 17:53:25 -0400 Subject: [PATCH] refactor(shell): remove admin UDS path and hard-disable ww shell --- CHANGELOG.md | 1 + doc/cli.md | 21 +-- doc/runtimes.md | 3 +- doc/shell.md | 121 ++------------- src/admin_uds.rs | 381 ----------------------------------------------- src/cli/main.rs | 64 +------- src/cli/shell.rs | 209 +++++--------------------- src/discovery.rs | 110 +------------- src/host.rs | 7 +- src/lib.rs | 2 - 10 files changed, 67 insertions(+), 852 deletions(-) delete mode 100644 src/admin_uds.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 80a28dc2..70a60420 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed - **`ww run` now requires a persistent identity by default.** Identity resolution no longer silently falls back to ephemeral keys when `--identity` is missing or points to a nonexistent file. Default lookup is `~/.ww/identity`; if absent, startup fails with a clear message and remediation. Operators can explicitly bypass with `--insecure-ephemeral` (named insecure on purpose), which restores prior ephemeral behavior for quick trial runs. +- **Admin UDS interface removed; `ww shell` is temporarily unavailable.** Removed the daemon-side Unix-domain admin service and its local socket discovery path. `ww shell` now remains as a forward-stable CLI surface but exits with `NOT IMPLEMENTED` until the replacement remote transport/auth path lands. - **Filesystem data-plane contract tightened: backend is now root-layer-only and `perform fs` reads are deprecated.** The shell/MCP evaluation wrapper no longer routes data-plane reads through `(perform fs ...)`; filesystem reads now go through WASI path I/O (`load`, `import`, `/ipfs/...`, `/ipns/...`). The legacy `fs` handler remains only to return a migration-grade deprecation error. Backend virtual mount resolution now rejects targeted mounts (`source:/guest/path`) and accepts root layers only; `ww run` enforces this early with a CLI preflight error that lists offending mounts. Docs updated across `doc/shell.md`, `doc/capabilities.md`, and `doc/architecture.md` to reflect the single-path model. - **CompilationService now uses a dedicated worker pool with in-flight dedupe.** The compiler subsystem moved from single-thread compile handling to a fixed worker pool (`WW_COMPILE_WORKERS`, default derived from CPU count), keys cache entries by `(wasm_blake3, engine identity)`, and coalesces concurrent duplicate compile requests so one cold compile serves all waiters. - **Shell local-discovery policy aligned to per-user run dir.** `ww shell` local discovery now scans only `~/.ww/run/` for `.sock` entries (no `/var/run/ww` fallback), and the client fails deterministically with a disambiguation error when multiple local daemons are present instead of prompting interactively. Updated `src/discovery.rs`, `src/cli/shell.rs`, and shell/CLI docs to match this behavior and keep the local admin auth boundary consistently user-scoped. diff --git a/doc/cli.md b/doc/cli.md index 34fc72f5..e8b7f70e 100644 --- a/doc/cli.md +++ b/doc/cli.md @@ -98,13 +98,11 @@ Connect to a running daemon and open a Glia REPL. ww shell [ADDR] [--discover] ``` -Today only the local-UDS path is implemented. The CLI surface for -remote shell access is forward-stable but currently exits with -`Error: NOT IMPLEMENTED`. +The admin UDS path has been removed. The command surface is preserved +for forward compatibility while remote transport/auth work lands, and +currently exits with `NOT IMPLEMENTED`. -- *(no args)* — connect to a local daemon over Unix Domain Socket at - `~/.ww/run/.sock`. Scans `~/.ww/run/`; if multiple daemons - are running locally it fails with a disambiguation error. +- *(no args)* — **NOT IMPLEMENTED.** - `` — **NOT IMPLEMENTED.** Future libp2p remote dial. - `--discover` — **NOT IMPLEMENTED.** Future mDNS LAN browse. @@ -115,7 +113,7 @@ compatibility; today both exit `NOT IMPLEMENTED`). ### Examples ```sh -ww shell # local UDS, auto-discover +ww shell # NOT IMPLEMENTED ww shell /dnsaddr/master.wetware.run # NOT IMPLEMENTED (clap parse OK) ww shell /ip4/127.0.0.1/tcp/2025 # NOT IMPLEMENTED (clap parse OK) ww shell garbage # clap parse error: invalid multiaddr @@ -123,13 +121,8 @@ ww shell garbage # clap parse error: invalid multiadd ### Auth model -The local UDS path is an admin endpoint. Filesystem permissions on -`~/.ww/run/` ARE the auth boundary — matching the -convention of `/var/run/docker.sock`, `~/.ipfs/api`, and -`~/.podman/podman.sock`. The spawned shell cell receives the daemon's -full membrane, unattenuated. There is no Noise handshake, no Terminal -challenge, no auth token. If you can write to the run dir, you can -admin the daemon. +No shell auth model is active right now because there is no live shell +transport path. The replacement design will use explicit remote auth. See [shell.md](shell.md) for Glia syntax and the capabilities the shell cell exposes. diff --git a/doc/runtimes.md b/doc/runtimes.md index 2235787d..e9240621 100644 --- a/doc/runtimes.md +++ b/doc/runtimes.md @@ -18,7 +18,6 @@ shared runtimes. |---|---|---|---| | Libp2p swarm | `swarm` (+ `ww-swarm-worker-*`) | `multi_thread` | TLS handshake parallelism — see below | | Epoch pipeline | `epoch` | `current_thread` | Single linear consumer of L1 events | -| Admin UDS shell | `admin-uds` | `current_thread` + `LocalSet` | Cap'n Proto RPC drivers are `!Send` | | Executor pool worker (×N) | `executor-N` | `current_thread` + `LocalSet` | `wasmtime::Store` is `!Send` | The default is `current_thread`. We pick it because: @@ -111,7 +110,7 @@ total thread budget is: - 1 `swarm` OS thread (the supervisor's child) - N `ww-swarm-worker-*` workers (tokio default) -- 1 `epoch`, 1 `admin-uds` +- 1 `epoch` - M `executor-*` (also `available_parallelism()`) On an 8-core Mac this lands around 18 threads under load, which is diff --git a/doc/shell.md b/doc/shell.md index d3fa021b..91ba949b 100644 --- a/doc/shell.md +++ b/doc/shell.md @@ -1,117 +1,18 @@ # Shell -The `ww shell` command opens an interactive Glia REPL against a running -wetware daemon. Today only the local-UDS path is implemented; the -forward-stable CLI surface for remote shell access (libp2p multiaddr, -mDNS LAN browse) is documented below but exits with -`Error: NOT IMPLEMENTED`. +The `ww shell` transport is currently unavailable. -## Connecting +The previous local admin UDS path has been removed, and the replacement +remote shell transport/auth path has not landed yet. For now, all +invocations of `ww shell` return `NOT IMPLEMENTED`. -```sh -ww shell # connect to local daemon via UDS -ww shell # NOT IMPLEMENTED — future remote dial -ww shell --discover # NOT IMPLEMENTED — future LAN browse -``` - -With no arguments, `ww shell` scans `~/.ww/run/` for `.sock` -files. If exactly one daemon is running locally, it connects. If -multiple are running, it fails with a deterministic error listing the -discovered sockets. If none are running, it errors with a hint to run -`ww run .` first. - -## Local admin gate (UDS) - -The local path is an admin endpoint by design. Whoever can write to -`~/.ww/run/` has full administrative control of the daemon — by -convention with `/var/run/docker.sock`, -`~/.ipfs/api`, `~/.podman/podman.sock`, and similar local-CLI sockets. -Filesystem permissions on the run directory ARE the auth boundary; -there is no Noise handshake, no Terminal challenge, no auth token. - -The spawned shell cell receives the daemon's **full membrane** — every -capability the daemon exposes, without attenuation. Admin scope is -exempt from epoch-based capability expiry: the shell remains usable for -the daemon's lifetime regardless of stem activity. - -If you need auth-gated remote shell access in the future, that's a -separate `:listen` registration via init.d, with a `Terminal(Shell)` -gate per the April-2 design — see the -[design doc](https://github.com/wetware/ww/issues/452) for context. +## CLI Surface (Forward-Compatible) -## Syntax - -Every expression is an S-expression. Effects use the `perform` form: -the first argument is the capability, the second is a keyword naming -the method, and the rest are method arguments. - -``` -(perform capability :method [args...]) +```sh +ww shell +ww shell +ww shell --discover ``` -Strings are double-quoted. Symbols are bare words. Comments start with -`;` and run to end of line. - -## Capabilities exposed to the shell - -The shell cell currently grafts these caps from its membrane (see -`std/shell/src/lib.rs::run_impl`): - -### host - -| Method | Example | Description | -| ---------- | ------------------------------------------------------------- | -------------------------------------- | -| `id` | `(perform host :id)` | Peer ID (bs58-encoded string) | -| `addrs` | `(perform host :addrs)` | Listen multiaddrs | -| `peers` | `(perform host :peers)` | Connected peers with addresses | -| `connect` | `(perform host :connect "/ip4/1.2.3.4/tcp/2025/p2p/12D3...")` | Dial a peer | -| `listen` | `(perform host :listen "/ip4/0.0.0.0/tcp/0")` | Listen on an additional address | - -### routing - -| Method | Example | Description | -| --------------- | --------------------------------------------- | ------------------------------------ | -| `provide` | `(perform routing :provide cid)` | Announce as provider for a CID | -| `findProviders` | `(perform routing :findProviders cid)` | Find providers for a CID over DHT | - -### Local effect handlers - -The shell cell wires Glia-only local helpers that do not go through a -remote capability: - -- **`import`** — load other Glia source files -- **`load`** dispatch form — read bytes from the WASI filesystem -- The kernel's built-in expressions: arithmetic, `let`, `if`, `defn`, - etc. - -`(perform fs ...)` is deprecated and returns a migration error. Filesystem -reads should go through normal WASI file I/O (`load`, `import`, and guest -file operations on `/ipfs/...` / `/ipns/...` paths). - -### Built-ins - -| Form | Description | -| ------------------ | -------------------------------------------------------- | -| `(def name value)` | Bind a value in the session environment (persists) | -| `(help)` | Print available capabilities and methods | -| `(exit)` | Disconnect cleanly | - -`def` state and any other env bindings persist for the lifetime of -the connection. A new `ww shell` session starts with a fresh cell -and an empty environment. - -## Connection model - -Each `ww shell` invocation spawns a fresh shell cell on the daemon -side via `executor.spawn_request()` — sessions are isolated by -construction. The cell exits cleanly when you `(exit)` or close stdin -(Ctrl-D). - -The daemon side bridges your `tokio::net::UnixStream` to the cell's -WASI stdio via the existing `handle_vat_connection_spawn` helper -(`crates/rpc/src/vat_listener.rs`) — the same one used by the libp2p -path. The cell itself doesn't know which transport you connected over; -it sees a generic Cap'n Proto duplex. - -For implementation details of the daemon-side service, see -[`src/admin_uds.rs`](../src/admin_uds.rs). +The command shape is intentionally preserved so the remote-shell rollout +can land without another CLI-breaking change. diff --git a/src/admin_uds.rs b/src/admin_uds.rs deleted file mode 100644 index ab1f29ec..00000000 --- a/src/admin_uds.rs +++ /dev/null @@ -1,381 +0,0 @@ -//! AdminUdsService — local admin gate over a Unix Domain Socket. -//! -//! Exposes the daemon's full membrane unattenuated on -//! `~/.ww/run/.sock`. Whoever can write to that directory has -//! full admin access — by design, matching the local-socket convention -//! of `/var/run/docker.sock`, `~/.ipfs/api`, `~/.podman/podman.sock`. -//! -//! Architecture: peer of [`SwarmService`] and [`EpochService`] — its own -//! thread with a `current_thread` runtime + `LocalSet`. Constructs its -//! own `Runtime` client (sharing the supervisor's backing state), -//! pre-loads `shell.wasm`, and binds the UDS at startup. Per-connection: -//! spawn a fresh shell cell instance and bridge the `UnixStream` to it -//! via the existing `handle_vat_connection_spawn` (generic over -//! `AsyncRead + AsyncWrite + 'static`). -//! -//! [`SwarmService`]: crate::services::SwarmService -//! [`EpochService`]: crate::services::EpochService -#![cfg(not(target_arch = "wasm32"))] - -use anyhow::{Context, Result}; -use std::path::PathBuf; -use std::sync::Arc; -use tokio::sync::{mpsc, watch}; -use tokio_util::compat::TokioAsyncReadCompatExt; - -use ::membrane::{Epoch, EpochGuard}; -use ed25519_dalek::SigningKey; - -use crate::discovery; -use crate::host::SwarmCommand; -use crate::launcher::create_runtime_client; -use crate::services::CompileRequest; -use crate::services::Service; -use crate::system_capnp; -use rpc::{ - routing::RoutingImpl, vat_listener::handle_vat_connection_spawn, CachePolicy, HostImpl, - NetworkState, -}; - -/// Configuration + shared state for the admin UDS endpoint. -/// -/// Constructed in the supervisor (`src/cli/main.rs::run_command`) once -/// the daemon's shared state is in place (after `SwarmService` reports -/// ready). Owns clones of the backing state it needs; everything is -/// `Send + Clone` at the boundary so the service can be moved onto its -/// own thread. -pub struct AdminUdsService { - /// Daemon's libp2p peer ID, used to compute the socket and metadata paths. - pub peer_id: String, - /// Bytes of `shell.wasm`, loaded into the per-thread `Runtime` at startup. - pub shell_wasm: Vec, - /// Listen multiaddrs (transport-only — no `/p2p/` suffix), written into - /// the metadata file for tooling consumers. - pub multiaddrs: Vec, - /// `ww` binary version string, written into the metadata file. - pub version: String, - pub network_state: NetworkState, - pub swarm_cmd_tx: mpsc::Sender, - pub wasm_debug: bool, - pub signing_key: Option>, - pub stream_control: libp2p_stream::Control, - pub ipfs_client: ipfs::HttpClient, - pub http_dial: Vec, - pub cache_policy: CachePolicy, - pub compile_tx: Option>, -} - -impl Service for AdminUdsService { - fn run(self, shutdown: watch::Receiver<()>) -> Result<()> { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .context("build admin-uds runtime")?; - let _span = tracing::info_span!("admin-uds").entered(); - - rt.block_on(async move { - let local = tokio::task::LocalSet::new(); - local.run_until(self.serve(shutdown)).await - }) - } -} - -impl AdminUdsService { - async fn serve(self, mut shutdown: watch::Receiver<()>) -> Result<()> { - // ── 1. Sentinel epoch guard for admin-scope caps. ───────────────── - // - // Admin does not enforce epoch-based capability expiry; the shell - // remains usable for the daemon's lifetime regardless of stem epoch - // advancement. We construct a guard whose `check()` never fires by - // pointing it at a never-changing watch channel. - let (epoch_tx, epoch_rx) = watch::channel(Epoch { - seq: 0, - head: Vec::new(), - provenance: ::membrane::Provenance::Block(0), - }); - // Hold the sender across the lifetime of this service so the - // receiver remains valid. - let _keep_alive = epoch_tx; - let guard = EpochGuard { - issued_seq: 0, - receiver: epoch_rx.clone(), - }; - - // ── 2. Build the `Runtime` client on this thread. ──────────────── - // - // The `Runtime` capability is `!Send` and lives on this single- - // threaded runtime. Internally it shares backing state with the - // rest of the daemon via the cloned channels and handles passed in. - // - // The epoch_rx is load-bearing: `src/launcher.rs:359` selects - // between `build_membrane_rpc` (which exports the cell's - // bootstrap capability through the WASI duplex back to the host) - // and `build_peer_rpc` (which does NOT) based on whether epoch_rx - // is `Some`. We need bootstrap export for `handle_vat_connection_spawn` - // to retrieve the cell's bootstrap cap, so we pass our sentinel - // receiver. The receiver never sees an epoch advance — admin scope - // is exempt from epoch-based capability expiry by design. - let runtime = create_runtime_client( - self.network_state.clone(), - self.swarm_cmd_tx.clone(), - self.wasm_debug, - None, // no epoch guard on the RuntimeImpl itself - Some(epoch_rx.clone()), - self.signing_key.clone(), - Some(self.stream_control.clone()), - None, - self.compile_tx.clone(), - self.cache_policy, - self.ipfs_client.clone(), - self.http_dial.clone(), - ); - - // ── 3. Pre-load shell.wasm. ────────────────────────────────────── - // - // The `Runtime` caches compiled `Executor` clients by content hash; - // subsequent loads of the same bytes return the cached executor - // immediately. Loading once at startup means per-connection spawn - // skips the wasmtime compile entirely. - let executor: system_capnp::executor::Client = { - let mut req = runtime.load_request(); - req.get().set_wasm(&self.shell_wasm); - let resp = req - .send() - .promise - .await - .context("runtime.load(shell.wasm) failed")?; - resp.get()?.get_executor()? - }; - tracing::info!( - bytes = self.shell_wasm.len(), - "shell.wasm loaded into admin runtime" - ); - - // ── 4. Assemble the full caps list. ────────────────────────────── - // - // Mirrors `HostGraftBuilder` (in `crates/rpc/src/graft.rs`) but as - // a flat `Vec<(name, client, schema_bytes)>` for direct hand-off - // to `handle_vat_connection_spawn` on every connection. - let caps = build_full_caps( - &self.network_state, - &self.swarm_cmd_tx, - self.wasm_debug, - &guard, - &self.stream_control, - &runtime, - &self.ipfs_client, - ); - - // ── 5. Bind the UDS with stale-socket recovery. ────────────────── - let socket_path = discovery::socket_path(&self.peer_id); - let listener = bind_with_recovery(&socket_path) - .await - .with_context(|| format!("bind admin UDS at {socket_path:?}"))?; - tracing::info!(?socket_path, "admin UDS bound"); - - // ── 6. Write the metadata file for tooling. ────────────────────── - let metadata_path = discovery::metadata_path(&self.peer_id); - if let Err(e) = write_metadata( - &metadata_path, - &self.peer_id, - &self.multiaddrs, - &self.version, - ) { - // Non-fatal: tooling-only artifact. - tracing::warn!(?metadata_path, error = %e, "failed to write admin metadata"); - } - - // ── 7. Accept loop. ────────────────────────────────────────────── - let result = loop { - tokio::select! { - accept = listener.accept() => match accept { - Ok((stream, _peer_addr)) => { - let exec = executor.clone(); - let caps = caps.clone(); - tokio::task::spawn_local(async move { - // `UnixStream: tokio::io::AsyncRead + AsyncWrite`. - // `.compat()` adapts it into the - // `futures::io::AsyncRead + AsyncWrite` traits - // that `VatNetwork` expects internally. - let stream = stream.compat(); - if let Err(e) = handle_vat_connection_spawn( - exec, - caps, - stream, - "local", - ) - .await - { - tracing::warn!(error = %e, "admin UDS connection ended with error"); - } - }); - } - Err(e) => { - tracing::warn!(error = %e, "admin UDS accept error (continuing)"); - } - }, - _ = shutdown.changed() => { - tracing::info!("admin-uds shutting down"); - break Ok::<(), anyhow::Error>(()); - } - } - }; - - // ── 8. Cleanup. ────────────────────────────────────────────────── - // - // Remove both files on graceful shutdown. SIGKILL is handled by - // the next start's stale-socket recovery (see `bind_with_recovery`). - let _ = std::fs::remove_file(&socket_path); - let _ = std::fs::remove_file(&metadata_path); - - result - } -} - -/// Assemble the full membrane cap collection for an admin-scope shell cell. -/// -/// Mirrors `HostGraftBuilder` (in `crates/rpc/src/graft.rs`) but returns the -/// flat `Vec<(name, client, schema_bytes)>` shape that -/// `handle_vat_connection_spawn` expects for forwarding into the spawned -/// cell's graft response. -/// -/// Includes: `host`, `runtime`, `routing`. Admin scope = full set. -/// (Today's shell cell uses only `host` and `routing` from graft; we also -/// expose `runtime` for future-proofing and to mirror the canonical graft -/// shape.) -#[allow(clippy::too_many_arguments)] -fn build_full_caps( - network_state: &NetworkState, - swarm_cmd_tx: &mpsc::Sender, - wasm_debug: bool, - guard: &EpochGuard, - stream_control: &libp2p_stream::Control, - runtime: &system_capnp::runtime::Client, - ipfs_client: &ipfs::HttpClient, -) -> Vec<(String, capnp::capability::Client, Vec)> { - let mut caps: Vec<(String, capnp::capability::Client, Vec)> = Vec::new(); - - // host - let host_impl = HostImpl::new( - network_state.clone(), - swarm_cmd_tx.clone(), - wasm_debug, - Some(guard.clone()), - Some(stream_control.clone()), - ); - let host: system_capnp::host::Client = capnp_rpc::new_client(host_impl); - caps.push(("host".to_string(), host.client, schema_for("host"))); - - // runtime (clone the singleton client) - caps.push(( - "runtime".to_string(), - runtime.clone().client, - schema_for("runtime"), - )); - - // routing - let routing_impl = RoutingImpl::new(swarm_cmd_tx.clone(), guard.clone(), ipfs_client.clone()); - let routing: ::membrane::routing_capnp::routing::Client = capnp_rpc::new_client(routing_impl); - caps.push(("routing".to_string(), routing.client, schema_for("routing"))); - - caps -} - -/// Look up canonical Schema.Node bytes for a core capability name. -/// -/// Core caps (`host`, `runtime`, `routing`, etc.) have their schemas baked -/// into the binary at build time by `crates/membrane/build.rs`. Unknown -/// names return an empty Vec; the graft path tolerates that with a warning. -fn schema_for(name: &str) -> Vec { - ::membrane::schema_registry::schema_by_name(name) - .map(|bytes| bytes.to_vec()) - .unwrap_or_default() -} - -/// Bind a Unix Domain Socket with stale-socket recovery. -/// -/// On Linux and macOS, UDS pathnames persist on the filesystem after the -/// listener closes — the kernel does *not* unlink them. If a previous -/// daemon was SIGKILLed, a stale socket file blocks `bind()` with -/// `EADDRINUSE`. The recovery protocol: -/// -/// 1. Attempt `bind()` directly. -/// 2. On `EADDRINUSE`, probe the existing socket by attempting `connect()` -/// with a 1-second tokio timeout. -/// 3. If the probe succeeds, another daemon is listening — bail out. -/// 4. If the probe fails (typically `ECONNREFUSED`), the file is stale — -/// `unlink` and retry `bind`. -async fn bind_with_recovery(socket_path: &PathBuf) -> std::io::Result { - use std::time::Duration; - - // Ensure parent directory exists. Best-effort; the caller already - // selected a writable run dir. - if let Some(parent) = socket_path.parent() { - let _ = std::fs::create_dir_all(parent); - } - - match tokio::net::UnixListener::bind(socket_path) { - Ok(listener) => Ok(listener), - Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => { - // Stale-socket recovery. - let probe = tokio::time::timeout( - Duration::from_secs(1), - tokio::net::UnixStream::connect(socket_path), - ) - .await; - match probe { - Ok(Ok(_)) => Err(std::io::Error::new( - std::io::ErrorKind::AddrInUse, - format!("another daemon is already listening on {socket_path:?}"), - )), - _ => { - // Either timeout (Err on the outer Result) or connect - // refused (Ok(Err(...))): treat as stale. - tracing::warn!( - ?socket_path, - "stale UDS detected (connect probe failed); unlinking and rebinding" - ); - std::fs::remove_file(socket_path)?; - tokio::net::UnixListener::bind(socket_path) - } - } - } - Err(e) => Err(e), - } -} - -/// Write the admin metadata JSON to disk. -/// -/// Schema (consumed by `ww status`, MCP tooling, and `ww shell` discovery): -/// ```json -/// { -/// "peer_id": "12D3KooW...", // bs58 -/// "multiaddrs": ["/ip4/127.0.0.1/tcp/2025", "..."], -/// "started_at": "2026-05-11T17:30:00Z", // RFC 3339 UTC -/// "pid": 12345, // OS process ID -/// "version": "0.1.0" // ww binary version -/// } -/// ``` -/// -/// All fields required. Consumers should treat a missing or malformed -/// file as "no metadata available" and fall back to `.sock` connect-probe -/// for liveness. -fn write_metadata( - path: &PathBuf, - peer_id: &str, - multiaddrs: &[String], - version: &str, -) -> Result<()> { - let pid = std::process::id(); - let started_at = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - let json = serde_json::json!({ - "peer_id": peer_id, - "multiaddrs": multiaddrs, - "started_at": started_at, - "pid": pid, - "version": version, - }); - let body = serde_json::to_vec_pretty(&json).context("serialize metadata json")?; - std::fs::write(path, body).with_context(|| format!("write {path:?}"))?; - Ok(()) -} diff --git a/src/cli/main.rs b/src/cli/main.rs index fa2cabdf..0f8b53ea 100644 --- a/src/cli/main.rs +++ b/src/cli/main.rs @@ -256,11 +256,8 @@ enum Commands { /// Connect to a running node and open a Glia REPL. /// - /// Evaluates Glia expressions on the remote node. State persists - /// across evals (def sticks). Ctrl-D or (exit) to disconnect. - /// - /// When no address is given, discovers a local node via UDS sockets - /// in `~/.ww/run/`. + /// Remote shell transport/auth is currently being reworked. + /// This command exists as a forward-stable CLI surface. /// /// Example: /// ww shell @@ -269,13 +266,10 @@ enum Commands { /// /// If both ADDR and --discover are given, ADDR takes precedence /// and --discover is ignored with a warning. (When ADDR / --discover - /// are implemented, both will use libp2p with Noise; today only the - /// local UDS path is built.) + /// are implemented, both will use libp2p with Noise.) Shell { /// Multiaddr of a remote node (NOT YET IMPLEMENTED — forward-stable /// CLI surface for future libp2p remote shell support). - /// If omitted, connects to the local daemon via UDS at - /// `~/.ww/run/.sock`. addr: Option, /// Browse the LAN for a wetware daemon via mDNS (NOT YET @@ -597,11 +591,7 @@ impl Commands { private_key, } => Self::push(path, ipfs_url, stem, rpc_url, private_key).await, Commands::Keygen { output } => Self::keygen(output).await, - Commands::Shell { addr, discover } => { - ww::config::init_tracing(); - let local = tokio::task::LocalSet::new(); - local.run_until(shell::run_shell(addr, discover)).await - } + Commands::Shell { addr, discover } => shell::run_shell(addr, discover).await, Commands::Perform { action } => match action { PerformAction::Install => Self::perform_install().await, PerformAction::Uninstall => Self::perform_uninstall().await, @@ -1555,42 +1545,6 @@ wasip2::cli::command::export!({iface_name}Guest); // Save values needed by the MCP cell before moving them into the kernel. let signing_key = std::sync::Arc::new(sk); - // Admin UDS thread: local-only admin gate over Unix Domain Socket. - // Lives alongside swarm/epoch/executor pool; clients connect via - // ~/.ww/run/.sock for full-membrane unattenuated access. - // FS permissions on the run dir ARE the auth boundary, by design — - // matching docker.sock / ipfs api / podman.sock conventions. - { - let snapshot = network_state.snapshot().await; - let peer_id_str = libp2p::PeerId::from_bytes(&snapshot.local_peer_id) - .context("invalid peer ID in network state")? - .to_string(); - let multiaddrs: Vec = snapshot - .listen_addrs - .iter() - .filter_map(|bytes| libp2p::Multiaddr::try_from(bytes.clone()).ok()) - .map(|ma| ma.to_string()) - .collect(); - supervisor.spawn( - "admin-uds", - ww::admin_uds::AdminUdsService { - peer_id: peer_id_str, - shell_wasm: EMBEDDED_SHELL.to_vec(), - multiaddrs, - version: env!("CARGO_PKG_VERSION").to_string(), - network_state: network_state.clone(), - swarm_cmd_tx: swarm_cmd_tx.clone(), - wasm_debug, - signing_key: Some(signing_key.clone()), - stream_control: stream_control.clone(), - ipfs_client: ipfs_client.clone(), - http_dial: http_dial.clone(), - cache_policy, - compile_tx: Some(compile_tx.clone()), - }, - ); - } - let mut builder = CellBuilder::new(image_path.clone()) .with_loader(Box::new(loader)) .with_network_state(network_state.clone()) @@ -1906,10 +1860,8 @@ wasip2::cli::command::export!({iface_name}Guest); } } - // Shell is now a daemon built-in served over UDS at - // ~/.ww/run/.sock — no init.d registration needed. - // (Remote shell over libp2p is a follow-up; when it ships, this - // is where its init.d entry would land.) + // Shell service registration is intentionally absent while remote + // shell transport/auth replacement is in progress. // ── Status init.d ──────────────────────────────────────────── let status_init = ww_dir.join("etc/init.d/05-status.glia"); @@ -2198,8 +2150,8 @@ wasip2::cli::command::export!({iface_name}Guest); done(format!("Binary symlink ({})", symlink_path.display())); // ── Default init.d ────────────────────────────────────────── - // Shell is now a daemon built-in served over UDS at - // ~/.ww/run/.sock — no init.d registration needed. + // Shell service registration is intentionally absent while remote + // shell transport/auth replacement is in progress. // ── Status init.d ──────────────────────────────────────────── let status_init = ww_dir.join("etc/init.d/05-status.glia"); diff --git a/src/cli/shell.rs b/src/cli/shell.rs index 534b3a3a..d4c86e60 100644 --- a/src/cli/shell.rs +++ b/src/cli/shell.rs @@ -1,187 +1,46 @@ -//! `ww shell` — thin REPL client connecting to a local daemon via UDS. +//! `ww shell` CLI surface. //! -//! Today, only the local-UDS path is implemented: -//! -//! ```text -//! ww shell # connect to ~/.ww/run/.sock -//! ww shell # NOT IMPLEMENTED (forward-stable CLI surface) -//! ww shell --discover # NOT IMPLEMENTED (mDNS browse, forward-stable) -//! ww shell --discover # NOT IMPLEMENTED (multiaddr precedence rule) -//! ``` -//! -//! FS permissions on `~/.ww/run/` are the auth boundary for the UDS path — -//! matching the convention of `docker.sock`, `~/.ipfs/api`, `~/.podman/podman.sock`. -//! Remote shell support (libp2p with Noise + Terminal auth) is a follow-up. +//! The UDS admin path has been removed. Remote shell transport/auth +//! replacement is tracked separately. -use anyhow::{Context, Result}; +use anyhow::{bail, Result}; use libp2p::Multiaddr; -use std::path::PathBuf; -use tokio::net::UnixStream; -use tokio_util::compat::TokioAsyncReadCompatExt; - -use ww::rpc::vat_dial; -use ww::shell_capnp; - -/// Discover a local daemon by scanning `~/.ww/run/` for `.sock` -/// files. Returns the path to the chosen socket. -/// -/// - 0 daemons found → error with a hint. -/// - 1 daemon found → return its socket path. -/// - >1 daemons found → fail with a deterministic disambiguation error. -fn discover_socket() -> Result { - let nodes = ww::discovery::list_local_nodes(); - match nodes.len() { - 0 => anyhow::bail!("no local wetware daemons found\n Start one with: ww run ."), - 1 => { - let node = &nodes[0]; - eprintln!( - "Connecting to {} ({})...", - node.peer_id, - node.socket_path.display() - ); - Ok(node.socket_path.clone()) - } - _ => { - let mut lines = vec![ - "multiple local wetware daemons found:".to_string(), - " stop extra daemons or remove stale sockets from ~/.ww/run/".to_string(), - ]; - for node in &nodes { - lines.push(format!( - " - {} ({})", - node.peer_id, - node.socket_path.display() - )); - } - anyhow::bail!("{}", lines.join("\n")); - } - } -} - -/// Shell prompt: dim `/` then `❯`. Rustyline 15 strips ANSI escapes -/// natively in `tty::width`, so no `\x01..\x02` zero-width markers needed. -const PROMPT: &str = "\x1b[2m/\x1b[0m ❯ "; /// Run the interactive shell client. /// /// `addr` and `discover` are the forward-stable CLI surface for remote -/// shell access (libp2p multiaddr / mDNS LAN browse). Both currently -/// exit with `Error: NOT IMPLEMENTED` — they exist so future remote -/// support doesn't break the invocation syntax. -/// -/// **Caller contract:** must be invoked on a tokio `LocalSet` (the capnp -/// `RpcSystem` is `!Send`). `cli/main.rs` wraps this with -/// `LocalSet::run_until(...)` already. +/// shell access (libp2p multiaddr / mDNS LAN browse). pub async fn run_shell(addr: Option, discover: bool) -> Result<()> { - // Forward-stable CLI surface; the server-side path isn't built yet. - // If both ADDR and --discover are given, ADDR takes precedence (per - // the priority rule in --help) — but both branches end the same way - // today, so we don't need to distinguish. - if addr.is_some() || discover { - eprintln!("Error: NOT IMPLEMENTED"); - std::process::exit(1); - } - - // 1. Discover the local daemon's socket. - let socket_path = discover_socket()?; - - // 2. Connect over UDS. No Noise, no Yamux, no protocol negotiation — - // the kernel completes the connect synchronously; we own the read - // + write halves of the stream as a single duplex. - let stream = UnixStream::connect(&socket_path) - .await - .with_context(|| format!("connect to {}", socket_path.display()))?; - - // 3. Bootstrap Cap'n Proto RPC via the paved-path helper. The helper - // spawns the RpcSystem driver before returning, so the Bootstrap - // roundtrip flows immediately and the cell is observably live by - // the time the first eval call fires. `.compat()` adapts the - // tokio AsyncRead+AsyncWrite UnixStream into the futures::io - // traits the helper expects. - let vat_dial::VatDial { - bootstrap: shell, - driver, - } = vat_dial::connect::<_, shell_capnp::shell::Client>(stream.compat()); - // Surface the eventual RpcSystem outcome for debugging session drops. - tokio::task::spawn_local(async move { - if let Ok(Err(e)) = driver.await { - tracing::debug!("Shell RPC session ended: {e}"); - } - }); - - eprintln!("{}", glia::banner()); - - // 4. REPL loop. rustyline is blocking, so run it on its own thread - // and bridge to the async eval loop via an mpsc channel. Eval - // output flows back through an `ExternalPrinter` so it interleaves - // with the live prompt instead of smashing into the next prompt - // line (rustyline draws the next prompt the moment the line is - // sent, before the async side has the eval result in hand). - use rustyline::ExternalPrinter as _; - let (line_tx, mut line_rx) = tokio::sync::mpsc::channel::(1); - let (printer_tx, printer_rx) = tokio::sync::oneshot::channel(); - std::thread::spawn(move || { - let mut rl = rustyline::DefaultEditor::new().expect("failed to create editor"); - let printer = rl - .create_external_printer() - .expect("failed to create external printer"); - let _ = printer_tx.send(printer); - loop { - match rl.readline(PROMPT) { - Ok(line) => { - if !line.trim().is_empty() { - let _ = rl.add_history_entry(&line); - } - if line_tx.blocking_send(line).is_err() { - break; - } - } - Err(rustyline::error::ReadlineError::Interrupted) => continue, - Err(rustyline::error::ReadlineError::Eof) => break, - Err(e) => { - eprintln!("readline error: {e}"); - break; - } - } - } - }); - - let mut printer = printer_rx - .await - .context("rustyline thread failed to initialize external printer")?; + let hint = if addr.is_some() || discover { + "remote shell is not implemented yet" + } else { + "local shell is temporarily unavailable while transport/auth is being reworked" + }; + bail!("ww shell: NOT IMPLEMENTED ({hint})") +} - while let Some(line) = line_rx.recv().await { - if line.trim().is_empty() { - continue; - } - let mut req = shell.eval_request(); - req.get().set_text(&line); - match tokio::time::timeout(std::time::Duration::from_secs(30), req.send().promise).await { - Ok(Ok(response)) => { - let result: shell_capnp::shell::eval_results::Reader<'_> = response.get()?; - let text = result.get_result()?.to_str().unwrap_or("(invalid UTF-8)"); - let is_error = result.get_is_error(); - if text == "exit" && !is_error { - break; - } - if !text.is_empty() { - let out = if is_error { - format!("error: {text}\n") - } else { - format!("{text}\n") - }; - let _ = printer.print(out); - } - } - Ok(Err(e)) => { - let _ = printer.print(format!("RPC error: {e}\n")); - break; - } - Err(_) => { - let _ = printer.print("eval timeout (30s)\n".to_string()); - } - } +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn shell_without_args_reports_local_unavailable() { + let err = run_shell(None, false).await.unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("local shell is temporarily unavailable"), + "unexpected error: {msg}" + ); } - Ok(()) + #[tokio::test] + async fn shell_with_addr_reports_remote_unimplemented() { + let addr: Multiaddr = "/ip4/127.0.0.1/tcp/2025".parse().unwrap(); + let err = run_shell(Some(addr), false).await.unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("remote shell is not implemented yet"), + "unexpected error: {msg}" + ); + } } diff --git a/src/discovery.rs b/src/discovery.rs index 80bfaf59..55f7d27d 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -1,17 +1,9 @@ -//! Local node discovery via Unix Domain Sockets and LAN Kademlia DHT. +//! Discovery primitives. //! -//! Running daemons open a UDS at `/.sock` (created by -//! [`AdminUdsService`](crate::admin_uds::AdminUdsService)) plus a metadata -//! JSON at `/.json`. Clients enumerate `*.sock` entries -//! to find live local daemons; the `.json` carries peer_id / multiaddrs / -//! pid / started_at / version for tooling consumers. -//! -//! The DHT discovery CID is also provided for LAN Kademlia advertisement. -//! That path is orthogonal to local discovery and unaffected by the -//! UDS migration. +//! Provides the well-known LAN Kademlia record key used by wetware nodes +//! to announce themselves. #![cfg(not(target_arch = "wasm32"))] -use std::path::PathBuf; use std::sync::LazyLock; /// Well-known CID that wetware nodes provide on the LAN DHT. @@ -29,99 +21,3 @@ pub static DISCOVERY_CID: LazyLock = LazyLock::new(|| { pub fn discovery_record_key() -> libp2p::kad::RecordKey { libp2p::kad::RecordKey::new(&DISCOVERY_CID.to_bytes()) } - -/// Canonical per-user run directory for socket and metadata files. -/// -/// This is intentionally user-scoped (`~/.ww/run/`) rather than system-scoped. -/// File ownership and directory permissions are the local auth boundary. -pub fn run_dir() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".ww/run") -} - -/// Primary directory for *writing* the socket and metadata files. -/// -/// Attempts to create `~/.ww/run/` and returns it regardless of whether -/// creation succeeds (bind/write calls will surface concrete errors). -fn writable_run_dir() -> PathBuf { - let dir = run_dir(); - let _ = std::fs::create_dir_all(&dir); - dir -} - -/// Path to the admin UDS socket file for the given peer. -/// -/// Joined under the writable per-user run directory. The socket -/// is created by `tokio::net::UnixListener::bind` at daemon startup; -/// clients connect via `UnixStream::connect(socket_path(...))`. -pub fn socket_path(peer_id: &str) -> PathBuf { - writable_run_dir().join(format!("{peer_id}.sock")) -} - -/// Path to the admin metadata JSON for the given peer. -/// -/// Lives alongside the `.sock` file and carries peer_id, multiaddrs, -/// started_at, pid, and version. Consumed by `ww status` and external -/// tooling. Not load-bearing for shell connection — the `.sock` file -/// (and a successful `connect()` to it) is the authoritative liveness -/// signal. -pub fn metadata_path(peer_id: &str) -> PathBuf { - writable_run_dir().join(format!("{peer_id}.json")) -} - -/// A locally running wetware daemon discovered via UDS socket file. -#[derive(Debug, Clone)] -pub struct LocalNode { - pub peer_id: String, - pub socket_path: PathBuf, -} - -/// List all locally running wetware daemons by scanning [`run_dir()`] -/// for `.sock` entries. -/// -/// Does not validate liveness — the caller should attempt `connect()` -/// and treat `ECONNREFUSED` as "stale socket, ignore this node". -pub fn list_local_nodes() -> Vec { - let mut nodes = Vec::new(); - let entries = match std::fs::read_dir(run_dir()) { - Ok(entries) => entries, - Err(_) => return nodes, - }; - - for entry in entries.flatten() { - let path = entry.path(); - // Only consider `.sock` files. Skip the `.json` siblings and - // any unrelated artifacts in the run dir. - let name = match path.file_name().and_then(|n| n.to_str()) { - Some(n) => n, - None => continue, - }; - let peer_id = match name.strip_suffix(".sock") { - Some(p) if !p.is_empty() && !p.starts_with('.') => p.to_string(), - _ => continue, - }; - nodes.push(LocalNode { - peer_id, - socket_path: path, - }); - } - - nodes.sort_by(|a, b| a.peer_id.cmp(&b.peer_id)); - nodes -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn run_dir_is_user_scoped() { - let dir = run_dir(); - let s = dir.to_string_lossy(); - assert!( - s.contains(".ww/run"), - "run_dir should point at ~/.ww/run, got: {s}" - ); - } -} diff --git a/src/host.rs b/src/host.rs index 5173104f..f0b93163 100644 --- a/src/host.rs +++ b/src/host.rs @@ -422,11 +422,8 @@ impl Libp2pHost { // Peers already seen as relay candidates (dedup). let mut seen_relay_peers: HashSet = HashSet::new(); - // Local discovery moved to UDS — see `src/admin_uds.rs`. The daemon's - // listen multiaddrs are written into `~/.ww/run/.json` at - // AdminUdsService startup; runtime changes to listen_addrs no longer - // get propagated automatically. (Acceptable for now: most consumers - // either dial the UDS directly or learn addrs via libp2p discovery.) + // Local UDS admin discovery has been removed. Runtime discovery now + // relies on libp2p mechanisms and direct multiaddr dialing paths. // Self-announcement on both DHTs. let beh = self.swarm.behaviour_mut(); diff --git a/src/lib.rs b/src/lib.rs index 769df5d6..37040040 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,8 +5,6 @@ // Host-only modules (not available for WASM guests) #[cfg(not(target_arch = "wasm32"))] -pub mod admin_uds; -#[cfg(not(target_arch = "wasm32"))] pub use cell; #[cfg(not(target_arch = "wasm32"))] pub mod daemon_config;