feat/tunnel-dialer #4

Merged
David merged 25 commits from feat/tunnel-dialer into main 2026-05-07 00:14:22 +02:00
Owner
No description provided.
Implements the agent half of the SPA's Desktop / Terminal / Files
tunnel flow. Server scaffolding (meshrelay rendezvous + cookie
minting) shipped earlier; this completes the round trip.

dispatch.rs: `Dispatcher` gains a `server_url: Option<String>` field
+ `set_server_url()` setter (called once at handshake time from
`net::Handshake::new` with the MeshServer URL parsed from .msh). The
existing `msg` arm in `route_json` now intercepts `type == "tunnel"`
before delegating to `host::msg::handle`; the freestanding msg
dispatcher has no access to dispatcher state so the handoff lives
here. Other msg sub-types (console, ps, sysinfo, ...) keep their
existing path.

host/tunnel.rs (new): the actual dialer + protocol loop.
- `expand_value_url`: turns the SPA's `*/meshrelay.ashx?...` form
  into an absolute connect target by reusing the agent's MeshServer
  URL's scheme + authority.
- `rendezvous_handshake`: waits for the server's Text `'c'` (or
  `'cr'` if recording), echoes the per-tunnel protocol byte
  (single ASCII digit). Mirrors `agent-redir-ws-0.1.1.js`'s
  `obj.State < 3` block.
- `run_terminal` (USAGE_TERMINAL=1): spawns the user's $SHELL on a
  fresh PTY, pipes binary frames between the relay WS and the PTY
  master. Text frames whose JSON body matches `ctrlChannel:102938`
  are echoed back so the SPA's rtt latency display populates;
  everything else is forwarded to the shell.
- `run_control_only`: stub for usage 2/4/5; completes the
  rendezvous + echoes ctrl messages so the SPA's onSocketConnected
  fires, but does not spin up Desktop/Files protocol layers yet.
- `control_echo`: parses Text frames, returns the verbatim echo
  iff the body is a JSON object with `ctrlChannel == 102938`.
  Tolerates both numeric and stringified channel ids (legacy SPA
  sometimes quotes the value).

8 unit tests cover URL expansion (relative -> absolute, absolute
pass-through, missing scheme rejected, ws + wss schemes) and
control echo (rtt round trip, string channel id, non-control text
rejected).

End-to-end verified against the dev SPA: msg/tunnel from a Linux
browser opens a /meshrelay.ashx tunnel, agent dials, server pumps,
shell prompt streams back to the browser, rtt control round trips.
All 219 meshagent lib tests pass (1 ignored, network-dependent).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Non-control text frames in the relay (the SPA's options JSON
envelope, recording metadata, etc) were being fed verbatim to the
PTY shell. Drop them. Real text data on a terminal tunnel is the
SPA's `~`-prefix SSH convention; we don't speak SSH yet so dropping
non-`~` text is the correct conservative default.

Pairs with the server-side fix that eats the per-tunnel protocol
byte. Together: SPA Terminal panel now opens to a clean shell prompt
and `echo hello-tunnel` round trips cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The SPA's relay terminal protocol carries two distinct resize signals:
the initial options envelope (`{type:"options", cols, rows, ...}`
sent by `agent-redir-ws.js` right after rendezvous) and subsequent
xterm.onResize control messages (`{ctrlChannel:102938, type:"termsize",
cols, rows}` from `default.handlebars::xTermSendResize`). Both must
trigger a `master.resize()` on the PTY so the shell sees SIGWINCH.

run_terminal: hold the PTY master in `Arc<Mutex<Box<dyn MasterPty +
Send>>>` so the async select branch can lock + resize between read /
write loops. Hand the cloned reader / writer to spawn_blocking
threads as before; resize is the only call from async land.

`classify_terminal_text` replaces the previous control_echo gate
with a 3-way enum: EchoControl (rtt + generic ctrl frames), Resize
(options OR ctrlChannel termsize), Drop (everything else - options
without cols/rows, recording metadata, non-JSON noise). The PTY
shell never sees these text frames, so an `echo`-of-the-options
JSON can't pollute its stdin any more.

4 new unit tests cover the classifier matrix; existing 8 still
pass.

End-to-end verified via SPA: a Terminal panel at xterm cols=9 rows=23
yields `tput cols`=9 / `tput lines`=23 inside the shell.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
run_files: speaks the SPA's `views/sharing.handlebars::CreateRemoteFiles`
JSON envelope. Today's MVP supports the `ls` action; the SPA renders
a read-only file tree off that, which is the most common operator
use case ("just look at what's on the box"). Mutating actions
(mkdir, rm, rename, download, upload, find) are accepted but no-op'd
for now; later phases plug `host::dir`/`host::file_transfer`/
`host::fileops` into the remaining arms.

build_ls_reply: reads `host::dir::list_dir`, sorts folders-first
then alphabetical to match the SPA's render order, and emits
`{action:"ls", path, reqid?, dir:[{n,t,s,d}, ...]}` where
`t` is 2=folder, 3=file, 4=symlink, 5=other. Errors return
`dir:[]` + `error` so the panel surfaces a message instead of
hanging.

Tunnel routing: USAGE_FILES (4) and USAGE_FILE_TRANSFER (5) both
land here. USAGE_DESKTOP keeps the control-only loop until KVM
streaming over the relay gets wired.

2 new unit tests cover the directory-listing reply and the
missing-path error path. End-to-end verified via direct WS:
`ls /etc` from the SPA returns 30+ entries with proper folder
flags + sizes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends `run_files` from the ls-only MVP to the full set of Files
panel actions the SPA's `views/default.handlebars::p13gotFiles`
emits. All ops echo `reqid` (or `id` for download) back so the SPA
can route per-dialog responses.

Atomic ops (each returns `{action:"refresh", reqid}` on success or
`{action:<orig>, error, reqid}` on failure):
- mkdir: host::fileops::mkdir
- mkfile: create-new an empty file
- rm: per-name fileops::delete loop, supports recursive flag from `rec`
- rename: fileops::rename(path/oldname -> path/newname)

Streaming ops:
- download: 3-step handshake (`{sub:"start"}` reply on path-open ->
  SPA sends `{sub:"startack"}` -> agent ships the first chunk ->
  loop on `{sub:"ack"}`). Each binary chunk is `[u32 control header
  big-endian | data]`; bit 0 of the header marks the end-of-stream.
  16 KiB chunks (legacy default).
- upload: agent opens dest with create + (truncate XOR append) per
  the SPA's `append` flag, replies `{action:"uploadstart", reqid}`,
  flow-controls each binary frame with `{action:"uploadack", reqid}`,
  finalises on `{action:"uploaddone"}`. The SPA prepends a 0 byte to
  binary chunks whose first raw byte would be `{` (123) or 0 to
  disambiguate from JSON command frames; we strip that lead byte.

findfile: bounded recursive walk (max depth 16, max 5000 results)
streaming `{action:"findfile", reqid, r:<full-path>}` per match and
a final `{action:"findfile", reqid, r:null}` to close the dialog.
Filter is case-insensitive substring.

5 new unit tests cover mkdir/mkfile/rm-success/rm-partial-fail/rename.
End-to-end direct-WS verified: a single tunnel runs mkdir + ls +
mkfile + rename + download (271 bytes of /etc/os-release returned
with end-flag) + rm in sequence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`just dev` keeps the existing cargo-watch-in-Docker behaviour. The
new `just dev-host` target runs the same agent binary directly on
the host - useful for fast iteration without container overhead and
for IDE/perf-tool attachment that can't reach into a container.

ensure-msh-host renders a host-mode counterpart (run/meshagent.host.msh)
that pins MeshServer to 127.0.0.1 since the host reaches the Docker
server stack via the port-mapped loopback rather than the
`dev-vervain-server-app-dev` container hostname. Server port still
flows from VERVAIN_HTTP_PORT in .env.

Separate data dir (run/meshagent-host.db) keeps the host agent's
identity files from clobbering the container agent's, so the two
can coexist (or alternate) without re-pairing. Both new paths are
gitignored.

Recipe bootstrap mirrors `dev`: seed .env if missing, re-invoke just
so the sub-invocation's parse-time dotenv-load picks up fresh values.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous version used run/meshagent-host.db as a separate data dir,
which generated a fresh agent identity per host run. The server then
registered it as a NEW node (different SPKI -> different nodeid),
leaving the original container nodeid stranded as a grey/offline
device in the SPA.

Switch the host recipe to use run/meshagent.db (the container's
data dir) so both modes present the SAME nodeid to the server. The
SPA sees a single device that flips connected/offline depending on
which mode is running.

Sled holds an exclusive lock on the data dir, so the two modes
cannot run simultaneously. The recipe now refuses to start when
the container agent is up and tells the operator to `just down`
first, instead of letting the lock collision bounce the connection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
run_desktop replaces the control-only stub for usage=2. Speaks the
legacy MeshCentral KVM binary protocol the SPA's
`agent-desktop-0.0.2.js::ProcessBinaryCommand` expects:

Outbound (agent -> SPA):
  cmd 7  Screen size: [W, H]
  cmd 11 Get displays: dcount, [disp_id], selected
  cmd 3  Tile: [X, Y, JPEG bytes]

Inbound (SPA -> agent):
  cmd 1  Key event: action + Windows VK keycode
  cmd 2  Mouse event: button byte + (X, Y), optional scroll delta
  cmd 5  Set encoding/quality
  cmd 6  Refresh -> StreamState::invalidate() forces every tile next frame
  cmd 8  Pause (no-op MVP)
  cmd 14 KVM_INIT_TOUCH (no-op until touch backend lands)
  cmd 87 Input lock toggle (no-op)

Streaming uses `meshagent_kvm::stream::capture_changed_tiles` (64x64
tiles, only changed ones shipped per frame), JPEG quality 50 default,
5 FPS. Initial frame ships screen size + a single-display reply
(dcount=1, disp[0]=1, selected=1) so the SPA's `onResize` fires.

Mouse: dispatches to X11 XTEST via `meshagent_kvm::mouse_move` /
`mouse_button`. Button byte decoded per the legacy SPA's
SendMouseMsg encoding (0x02/0x04 = LEFT down/up, 0x08/0x10 = RIGHT,
0x20/0x40 = MIDDLE, 0x88 = double-click as down/up/down/up). Scroll
on size 12: i16 delta picks ScrollUp / ScrollDown.

Keys: small VK->keysym table for control keys + ASCII letters/digits
+ F1..F12. Printable Unicode keys (cmd 85 KEYUNICODE) wire up later.

Wayland sessions fall through to XWayland via meshagent_kvm's
existing X11 path (DISPLAY=:1) when grim is not installed; native
wayland-protocols screencopy ships in a follow-up.

19 tunnel unit tests still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both the rendered .msh and the sled data dir for the host-run agent
now live in run-host/ instead of mixing with run/ (which the Docker
container bind-mounts). The two runs no longer share any path; each
keeps its own identity files and the server registers them as
distinct devices.

run-host/ is gitignored. Old run/meshagent-host.db / run/meshagent.host.msh
patterns dropped from .gitignore (the directory rule covers it).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
KDE KWin and GNOME Mutter don't expose wlr-screencopy-unstable-v1,
so `grim` exits non-zero. The X11 fallback can't help either: those
compositors back XWayland with a non-readable root window, and
GetImage on the root returns BadMatch (matches the
`bad_value: 858 = root XID 0x35a` error users were hitting on KDE
Plasma 6 Wayland).

Add the `ashpd` crate (pure-Rust XDG portal client) and route
`capture_rgb` / `capture_jpeg` through grim first, then the
`org.freedesktop.portal.Screenshot` interface on `NotAvailable`.

`capture_rgb_via_portal`:
- Calls `Screenshot::request().interactive(false).modal(false).send()`
  (compositor honours an "always allow" toggle so subsequent calls
  skip the prompt).
- Strips `file://` from the returned `ashpd::Uri`, reads the PNG,
  decodes via `image` (PNG-only feature), drops the tmp file.
- Returns RGB8 bytes the existing tile-diff + JPEG path consumes
  without changes.

`capture_jpeg`:
- Same chain, but re-encodes the portal RGB through `jpeg-encoder`
  (already a dep) so the wire format is unchanged.

Build deps added: `ashpd 0.13` (features: tokio + screenshot only -
keeps build time bounded), `image 0.25` (default-features=false +
png + jpeg). Runtime deps are `xdg-desktop-portal` + a backend
(xdg-desktop-portal-kde / -gnome / -wlr); already installed on
typical desktop hosts.

Caveat: each capture is a DBus roundtrip + tmp PNG IO; way slower
than wlr-screencopy. Fine for 5fps MVP; the proper high-fps path is
the ScreenCast portal + PipeWire stream consumer (lands alongside
`pipewire-rs` integration in a follow-up).

24 meshagent-kvm tests pass (existing `capture_returns_not_available_
without_grim` relaxed to accept portal-path outcomes when DBus is
unavailable in CI).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`capture_rgb` / `capture_jpeg` were probing `grim` on every frame
(5fps -> a log line every 200ms on KDE/GNOME hosts where grim is
absent). One-shot atomic flag flips on the first `NotAvailable`;
subsequent captures go straight to the portal path. Saves the
exec(grim) cost too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
X11 XTEST input is silently dropped by KWin / Mutter on Wayland - the
synthetic events never reach Wayland-native clients. Add a Wayland
input path through the xdg-desktop-portal RemoteDesktop interface,
which is the only sandboxed-but-capable input route on those
compositors.

`meshagent_kvm::portal` (new module): persistent joint
RemoteDesktop+ScreenCast session opened lazily on first input call
via `OnceCell`. KDE prompts the user for "remote control" permission
once per session (PersistMode::Application keeps the grant across
agent restarts within the same login). Module exposes
`pointer_motion_absolute`, `pointer_button` (Linux evdev BTN_*),
and `keyboard_keysym` (X11 keysym numbers) plus the global
convenience wrappers used by the dispatcher.

`tunnel.rs::handle_desktop_input` branches on
`portal::prefer_portal_input()` (true on Wayland sessions): mouse +
key calls go through the portal helpers; X11 / non-Wayland still
uses the existing meshagent_kvm XTEST helpers. Added:
- `button_byte_to_evdev` mapping the legacy mouse button byte to
  BTN_LEFT / BTN_RIGHT / BTN_MIDDLE.
- `is_button_press` distinguishing the press half of the button-byte
  encoding from the release half.
- `vk_to_keysym_num` returning numeric X11 keysyms (the portal call
  expects i32, while the X11 XTEST path takes the symbolic name).

ScreenCast frames are still served by the slow Screenshot portal
fallback in `wayland::capture_rgb_via_portal`. The PipeWire stream
consumer that turns the active ScreenCast session into a real high-fps
frame source ships in a follow-up; this commit unblocks input in
the meantime.

Build deps: `ashpd` features `screencast` + `remote_desktop` (in
addition to the existing `screenshot`); `pipewire 0.9` declared for
the upcoming consumer (no code uses it yet, but ships it pre-built so
the next commit can wire it without re-pulling compile time).

19 host::tunnel tests still green; meshagent-kvm builds clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Screenshot portal one-shot path is functional but cannot stream
faster than ~1 fps - each call costs a DBus roundtrip + compositor
render + tmp file PNG IO. KDE additionally reset the cursor to the
screen centre on every screenshot, producing the "cursor jumps to
centre every few seconds" symptom users reported. Replace with a
live PipeWire stream consumer driven by the same RemoteDesktop
session that already handles input.

`PortalSession::open` now also calls `open_pipe_wire_remote` and
spawns a dedicated OS thread (`kvm-pipewire`) running the PipeWire
main loop. The thread:
  1. Connects to the compositor's PipeWire daemon via the portal-
     issued fd.
  2. Creates a Stream against the screencast node id.
  3. Negotiates a packed-RGB format from a permissive enum
     (BGRx / RGBx / BGRA / RGBA / RGB / BGR; resolution 1x1..8192x8192;
     framerate 0..240 fps).
  4. On every `process` callback, dequeues the buffer, converts to
     RGB8 (handling stride padding + per-format byte swizzle) and
     swaps the result into `latest_frame`.

`capture_rgb_via_portal` reads `latest_frame` first - that's a
memcpy + clone, no IPC - and only falls back to the slow Screenshot
portal when the consumer thread hasn't produced a frame yet (early
in the session, before the first format negotiation completes).

`convert_to_rgb` covers the six packed formats we advertise; padded
rows handled by skipping the per-row trailing bytes past
`width * bpp`. Unsupported formats log + drop the buffer; the
compositor will pick another from the enum on the next negotiation.

Existing capture test relaxed: with a live dev compositor + the
portal path, capture can succeed even without DISPLAY/WAYLAND_DISPLAY
set; the test now sanity-checks that the agent doesn't panic and
accepts any non-error outcome.

24 meshagent-kvm tests pass. Cursor-jump artifact gone; frame rate
limited only by JPEG encode + relay pump now (next: bump from 5fps
default to 15-30fps once we measure encode cost).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 5fps default was a hack for the Screenshot-portal era when each
frame cost ~200 ms of DBus + tmp PNG IO. PipeWire frames are ~free
to read (they're already in shared memory), so the agent can run at
the SPA's actual requested cadence. Default now 33ms (~30fps).

cmd 5 (Set encoding/quality) reads bytes 8-9 as the frametimer the
SPA's encoder-settings dialog ships, in milliseconds. Clamp to
[16, 1000] ms (60fps to 1fps) so a 0 from a buggy SPA doesn't
busy-loop the encoder and a huge value doesn't stall the stream.
After cmd 5, the desktop loop rebuilds its tokio interval if the
period changed (Interval doesn't expose runtime period mutation).

19 host::tunnel tests still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The SPA's Desktop panel polls the remote clipboard
(`{action:"msg", type:"getclip", tag:N}`) and writes back via
`type:"setclip", data:<text>` from its clipboard dialog and the
desktop-side paste shortcut. Until now both were silently dropped
("unhandled msg type" debug line per request).

`reply_getclip`: shells out to `wl-paste --no-newline --type text/plain`
when WAYLAND_DISPLAY is set; otherwise `xclip -selection clipboard
-o`. KDE Plasma + GNOME hold the active selection on the Wayland
clipboard, not the X11 selection (XWayland exposes a separate one),
so the wl-clipboard tools are the only ones that see what the user
actually copied. Empty clipboard or absent tool yields an empty
string so the SPA's `message.data` stays a well-formed string.

`reply_setclip`: same tool selection in reverse, piping the
SPA-supplied `data` field into the tool's stdin. Replies
`{success: bool}` so the SPA's clipboard-dialog status flashes
green on success / red on failure.

System deps: `wl-clipboard` and/or `xclip` on PATH. Both already
installed on the dev host (`/usr/bin/wl-paste`, `/usr/bin/wl-copy`,
`/usr/bin/xclip`) so this works out of the box.

2 new unit tests cover the reply envelope shape (data field is a
string for getclip; success is a bool for setclip). Existing 8
host::msg tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`host::msg::handle` previously dropped these sub-types as "unhandled"
debug. Wire each one with the legacy SPA's expected reply envelope.

cpuinfo: surfaces `host_cpuinfo::snapshot()` (logical processors,
model, vendor, mhz, cache size). Same data the sysinfo bundle
includes, but emitted as its own msg sub-type so the deskTools cpu
dialog can fetch it standalone.

pskill: SIGTERM the requested pid via `libc::kill`. Replies
`{success: bool, error?: <io::Error>}`. Rejects pid 0 and propagates
errno text on failure.

openUrl: `xdg-open <url>` (`open` on macOS, `cmd /c start` on
Windows). Rejects non-http(s) schemes so an attacker can't push
`file://...` or `javascript:` through the agent.

serviceStart / serviceStop / serviceRestart: shells out to
`systemctl <action> <unit>`. Conservative unit-name filter
(`is_safe_unit_name`) restricts to `[A-Za-z0-9_.@-]` and a 256-byte
cap so a stray request can't smuggle `; rm -rf /` into the
command line. Replies `{success: bool, error?: <stderr>}`.

5 new unit tests cover cpuinfo shape, openUrl scheme filter, pskill
on invalid pid, the unit-name filter directly, and a service-action
attempt with an injection attempt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The legacy SPA emits both names interchangeably for "pop a dialog
on the agent host." Wire both via a tiered fallback:
  1. kdialog --title T --msgbox BODY   (KDE)
  2. zenity --info --title T --text BODY  (GNOME / GTK)
  3. notify-send T BODY                (libnotify; transient)

Replies `{success: bool, error?: ...}`. Empty body -> success=false
without launching anything. If none of the three tools is on PATH,
returns `error: "no notification tool available"`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a tunnel handler for the SPA's `uploadhash` action so the resume-upload + skip-existing-file paths in `p13gotUploadData` actually fire. SPA already sends `{ action: 'uploadhash', reqid, path, name, tag: { h, s, skip } }` before each upload when a same-named smaller file exists; the agent now hashes the first `tag.s` bytes of the on-disk file with SHA-384 and replies `{ action: 'uploadhash', reqid, tag, hash: <upper-hex> }`.

The SPA compares `cmd.tag.h === cmd.hash`:
- equal + tag.skip => skip the file (already uploaded).
- equal + !tag.skip => append starting at byte tag.s.
- mismatch => overwrite from byte 0.

Without this handler the SPA's `uploadhash` send never got a reply, so the resume codepath was effectively dead and every upload re-transmitted from byte 0. Hash is computed in 64 KiB chunks to keep big files off the heap.
The OOB-update signing key is plumbed end-to-end via `net.rs::run_session` calling `DispatchContext::with_oobupdate_signing_key(server_signing_key)`. Replace the dangling TODO with a one-line note saying "the production path uses the AuthVerify pubkey; the None case stays reserved for tests / pre-handshake sanity refusals."
The doc on `oobupdate_signing_key` claimed the trust anchor wasn't yet wired in production, which has been false since `net.rs::run_session` started calling `with_oobupdate_signing_key(server_signing_key)`. Update the comment to describe the actual state - production plumbs the AuthVerify pubkey through; `None` is reserved for tests and pre-handshake sanity refusals.
The container build (`compose.yml` -> `cargo watch ... run --package meshagent --features dev-insecure`) failed because `pipewire` (transitively `libspa-sys`) requires `libpipewire-0.3` headers at compile time, which the agent's Debian-slim image doesn't ship. Headless / containerised agents don't have a Wayland session anyway, so pulling pipewire + libspa + libclang for nothing is the wrong default.

Make the portal stack opt-in:

- `meshagent-kvm`: new `wayland-portal` feature (off by default) gates the `ashpd`, `pipewire`, and `image` deps. `portal.rs` is feature-gated; a no-op stub module replaces it when the feature is off (`prefer_portal_input` returns false, the free `pointer_*` / `keyboard_keysym` helpers are no-ops, the `btn` constants stay available since the input mapper uses them regardless of the backend). `wayland.rs::capture_rgb_via_portal` is feature-gated; the no-feature version returns `KvmError::NotAvailable` so `capture_rgb` surfaces the grim error directly.
- `meshagent`: forwards as `meshagent/wayland-portal -> meshagent-kvm/wayland-portal`. Workspace dep gets `default-features = false` so the manifest-level override actually applies (without that, cargo warns and silently re-enables defaults).
- `justfile dev-host-internal`: enables `wayland-portal` for desktop dev where the host has libpipewire installed; the in-container `dev` recipe leaves it off, matching the slim image's package set.

Build matrix verified locally:
- `cargo build --workspace` (default, no portal): clean.
- `cargo build -p meshagent --features dev-insecure`: clean.
- `cargo build -p meshagent --features dev-insecure,wayland-portal`: clean.
- `host/tunnel.rs`: real `copy` + `move` handlers next to the existing `ls`/`mkdir`/`rm`/`rename`. SPA's "Paste" button emits `{action:"copy"|"move", scpath, dpath, names:[...]}`; we walk the names list calling `std::fs::copy` / `std::fs::rename` per entry, surface the first error if any, otherwise reply `refresh` so the dialog re-lists. Closes the long-standing "msg/tunnel files: action not yet implemented" debug log for these two SPA verbs.
- `host/process.rs::kill`: refresh the doc comment to be honest about why Windows support is held - it'd require pulling in `windows-rs` just for `OpenProcess` + `TerminateProcess` and the agent's other Windows-specific paths haven't done so either, so adding the dep here would be premature.
- `net.rs`: refresh the module docstring's "wired but not yet used" claim about `ServerTlsCertHash`. The cache *is* used for the agentcore.c:3640-3653 fast-path (preemptive AuthConfirm on reconnect when the hash matches); the comment was stale.

Windows process kill, Windows update apply, and a real LAN-side mDNS listener each need their own dedicated work and stay in the existing platform-cfg arms.
Two modes, both Linux-only for now:

- `meshagent install --system --msh server-issued.msh` (root):
  - Copies `current_exe()` to `/usr/local/sbin/meshagent` (mode 755).
  - Persists the `.msh` at `/etc/meshagent/meshagent.msh` (mode 600 - it carries the ServerID hash + MeshID).
  - Creates `/var/lib/meshagent/` for the sled store.
  - Creates the `meshagent` system user (`useradd --system --no-create-home --shell /usr/sbin/nologin`) and chowns the state dirs to it.
  - Writes `/etc/systemd/system/meshagent.service` with the standard hardening sandbox (`NoNewPrivileges`, `ProtectSystem=strict`, `ProtectHome`, `ProtectKernelTunables`, etc.) and `User=meshagent`.
  - `systemctl daemon-reload && systemctl enable --now meshagent.service`.

- `meshagent install --user --msh ...` (no root):
  - Same flow but rooted at `~/.local/bin`, `~/.config/meshagent`, `~/.local/share/meshagent`, `~/.config/systemd/user`.
  - Runs inside the user's session so X11/Wayland/clipboard access works for KVM. No sandbox directives - they break session-bus / Wayland-socket access.
  - `systemctl --user daemon-reload && systemctl --user enable --now meshagent.service`.

Uninstall (both modes) is the inverse and idempotent - every step (disable + remove unit + drop binary + drop .msh) tolerates "not present". State directories are deliberately preserved on uninstall so an admin doesn't accidentally lose audit-relevant agent state; it's noted in the log message and can be removed manually.

CLI:
- New `Cmd::Install { --system | --user, --msh }` and `Cmd::Uninstall { --system | --user }` subcommands.
- The legacy implicit-run flow (no subcommand, just `--msh ... --data-dir ...`) keeps working unchanged - the dev `cargo watch` loop in `compose.yml` doesn't need a wrapper change.
- `--once` accepted at both the top level (legacy) and on the explicit `Run` subcommand; top-level wins on conflict.

3 new unit tests cover the unit-file rendering + user-layout path resolution. The full agent test suite has some pre-existing parallel-test flakiness in PTY / TCP-forward paths that surfaces once the test count goes up; those tests pass cleanly in isolation and aren't related to this change.
feat: wasm meshcore sandbox + loadcore action
Some checks failed
Check / clippy + fmt + tests (pull_request) Failing after 3s
581951d47f
Replaces the legacy JS-meshcore extensibility model with a wasmtime sandbox the agent runs locally. Server pushes signed wasm modules via a new `loadcore` JSON action; agent verifies + persists + hot-loads them; the dispatcher forwards every unrecognized JSON verb to the loaded guest's `on_event_json` export.

New crate feature: `meshcore` (default-on). Pulls in `wasmtime = 26` (cranelift + runtime). Disable for minimal agent builds:
`cargo build --no-default-features`. The runtime adds ~5 MB to the binary.

New module `meshagent::core`:
- `MeshCoreInstance::from_bytes` validates the module has the required exports (`memory`, `alloc`, `on_event_json`), constructs a fuel-limited (100M units / call) wasmtime engine + Module, computes SHA-384.
- `on_event_json(action, payload) -> Option<Vec<u8>>` calls the guest's export. Wire ABI: guest's `alloc(len) -> i32` returns a buffer, host writes the action bytes + JSON payload, calls `on_event_json(action_ptr, action_len, payload_ptr, payload_len) -> i64`. The packed return value is `(reply_ptr << 32) | reply_len`; 0 means "no reply".
- `load_from_action(data_dir, store, signing_key, envelope)` verifies the action's signature against the AuthVerify cert pubkey (same trust chain as `oobupdate`), fetches the URL, verifies SHA-384 matches, persists the bytes at `<datadir>/meshcore.wasm`, returns the loaded instance.
- `load_persisted(data_dir)` rehydrates a previously-pushed module on agent startup.
- `MeshCoreSlot` is the hot-swappable Arc<Mutex<Option<...>>> that the dispatcher consults; `try_dispatch(action, payload)` is a one-call shortcut for the unrecognized-verb path.

Dispatch wiring (`dispatch.rs`):
- New `DispatchContext` fields (feature-gated): `meshcore` slot, `store`, `data_dir`, with a `with_meshcore(...)` builder.
- New `loadcore` arm calls `core::load_from_action`, installs into the slot, replies `{ok: true, hash}` or `{error}`.
- The `other =>` arm forwards to `slot.try_dispatch(action, envelope)`; the slot returns `None` when no core is loaded or when the guest opts out, falling through to the existing "unhandled" debug log.

Net wiring (`net.rs`):
- `run_connection`, `run_reconnecting`, and `Handshake::new` take a new `&Path data_dir` arg.
- `Handshake::new` constructs a fresh `MeshCoreSlot`, attempts `core::load_persisted` to rehydrate, attaches the slot via `ctx.with_meshcore` on every JSON dispatch.

Main wiring (`main.rs`):
- Both run-paths pass `&data_dir` through to net.

Tests: 4 new units in `core::tests` (build minimal `(module ...)` via `wat`, verify `from_bytes` rejects missing exports, verify the empty-reply path, verify the empty-slot dispatch path). `wat = "1"` added to dev-dependencies.

Future: when an actual extension hits the limits of the verb-forwarding model (host imports for db/log/event/fetch_url, manifest declarations, signature-bound permissions), grow the host-import surface from inside `core::on_event_json` -- the wasmtime `Linker` is the single point that needs to register imports.
David merged commit f525ed75d0 into main 2026-05-07 00:14:22 +02:00
David deleted branch feat/tunnel-dialer 2026-05-07 00:14:22 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/vervain-agent!4
No description provided.