Skip to content

Changelog

All notable changes to msks are documented here. The format follows Keep a Changelog; versions are tagged vX.Y.Z.

[Unreleased]

[v0.6.0] - 2026-11-02

Changed

  • devenv processes up on a macOS host (#707). The msksd process is registered on Linux hosts only, so on a Mac the command exits at once (No processes defined) instead of supervising the dev daemon's platform refusal into a restart loop. msks-dev's refusal on a Mac points at the new Developing on a macOS host chapter, which documents both ways a Mac reaches a live daemon: the client over an ssh tunnel to a Linux dev host, and the product Lima host installed from a built wheel (msks-setup-macos-host --wheel).
  • msks-setup-macos-host without --port (#699). The run now talks through the forward the existing msksd VM already carries — the forward is fixed at VM creation, so the VM's own answer is the deployment's port — and finishes the verify pass, the client config, and the preset on it. A VM created with a non-default host port needs no recreation; an explicit --port that disagrees with the existing VM's forward stays a named refusal naming the recreation remedy.

Removed

  • The mount_shares config feature (#685). Host paths are the one share surface: the mount_shares key and MSKSD_MOUNT_SHARES are removed, msks create --share / msks mount HOST:GUEST[:MODE] are the one grammar, and the TUI's mounts editor takes host paths only. A config that still names the key or the env var fails at load with a message pointing at mount_host_roots and --share; workspaces whose stored rows carry named mounts drop them at read and at the next set (they mount nothing for them at the next start) — recreate those mounts with host paths.

Added

  • Version skew between the client and the daemon is named (#706). Every msks invocation but the image group (whose check member runs offline) makes one best-effort /health roundtrip and prints a notice naming both versions and the remedy the skew's direction asks for (#715): a newer release daemon names the client's update, a development build that leads names the tree it came from — never a rerun that would downgrade it. The setup tool's check pass grows a row pairing the VM's daemon version with the tool's own, and the bootstrap refuses — with the version pair named, ahead of the unit restart — when the daemon it installs is not the tool's build or rejects the config the same run writes, instead of crash-looping behind the 90s verify timeout (#704's shape).

  • The macOS share stack documented (#675). The mounts chapter gains the macOS share geometry — a Mac folder crosses vz virtiofs into the daemon's VM under /mnt/msks, then virtiofsd into the workspace — with the four contracts that cross with the tree: watchers on shared trees run in polling mode (file events do not cross virtiofs), case and Unicode normalization follow the Mac volume, an rw share names the workspace its single writer (Mac-side writers honor none of its locks), and a volume shared into a running workspace ejects only after the workspace stops.

  • Lima self-install in msks-setup-macos-host (#699). A Mac without Lima now gets it through brew install lima run by the tool itself, so a stock Mac reaches a working msksd in one run; a Mac without brew names the Homebrew line and stops.

  • msks-setup-macos-host writes the client config and a login agent (#689). The tool now writes msks.yaml itself — the daemon alias pointing a bare msks at the forwarded port with the token file and CA beside it — and a launchd agent runs limactl start msksd immediately on install and at every login, so the daemon answers now and after a reboot with no exports in the shell profile. An operator's own msks.yaml is kept untouched (the printed exports remain the copy-paste fallback); --check reports both the config file and the agent, and --uninstall removes the agent along with the VM.

  • msks-preflight prints per-section durations (#690). Every gate section carries a wall-clock column and a total in the summary, a --collect-only pass names an unimportable module in seconds before the suite runs, and the suite run itself carries a 40-minute wall-clock budget that fails loudly — a wedged run can no longer hang a pre-flight session indefinitely.

  • msks-setup-macos-host (#676). The macOS host setup: a stock Apple-Silicon Mac reaches a working msksd in one run — the tool creates the msksd Lima VM from the shipped template (the invoking user's home mounted under the reserved prefix, the daemon's port forwarded on both loopbacks), installs the daemon inside it (the PyPI release matching the tool's version, or --wheel for a build in hand), writes its config with host-share mode prefix and the user's home the default allowed root, the daemon's tool set (egress plumbing, volume trio, seed tooling) with its kernel routing setting and the pinned cloud-hypervisor release beside it, enables its service, and prints the client preset (MSKSC_URL/MSKSC_TOKEN/MSKSC_CAFILE — the CA written under ~/.config/msks/). --check runs the doctor rows (Lima at the template's floor, the VM running, the nested-virtualization marker, virtiofsd inside, the listener answering); --uninstall deletes the VM. Every phase is idempotent; reruns preserve the token, the CA, and the daemon's state.

  • msks image check grows the virtiofs row (#674). The guest-image capability check now probes the virtiofs filesystem driver a share mounts through: Debian's pinned kernels build the driver in; Fedora's image ships it as a module in the runtime closure — current images pass, and an image without it fails its own row with the named gap. A workspace that attaches shares on such an image reports the same named gap on the guest's console and cloud-init log at start (the per-boot mount installer checks the driver before its units come up).

  • msks create --share HOST:GUEST[:MODE] and the host form on msks mount/umount (#673). The client surface for #667's host-path shares: a repeatable create flag whose ~ expands against your home before the request, the same spec grammar on msks mount, and msks umount names what leaves by host path. A refused share surfaces the daemon's gate message verbatim — which gate refused and the remedy. See the mounts chapter.

  • Host-path workspace shares behind the #667 gates (#672). A workspace's mounts now take a host-path form — mounts: [{host: ~/code, guest: /mnt/code, mode: rw}] — alongside the named-share form: the daemon translates the path by its host mode (MSKSD_MOUNT_HOST_MODE, identity on a bare-Linux host or prefix on the Lima/macOS host), gates it against the roots its VM actually mounts and the allowed roots in MSKSD_MOUNT_HOST_ROOTS (both refusing at set time with their remedy: VM recreation, or the config edit), and refuses symlinks that resolve outside what the operator allowed. The host form defaults to rw, the same default the named form carries — the trust decision (the share listed or the root allowed) precedes the mode. The virtiofsd lifecycle, the cloud-hypervisor --fs wiring, and the guest mount units are the #620 machinery, keyed by a tag derived from the path. See the mounts chapter for the two gates and their refusal remedies.

  • Lima template share roots under the reserved prefix (#671). The msksd-VM Lima template ships in the wheel (msks/server/assets/msksd-vm.yaml) with every share root mounted under /mnt/msks — the WSL /mnt/c shape, so an operator's /Users/name appears at /mnt/msks/Users/name — and the default root (the invoking user's home, writable) declared at VM creation. The host-share vocabulary in msks.spec.mounts carries the prefix, the host-mode translation (identity on bare Linux, prefix on VM hosts), and the template validation that refuses a mount outside the reserved-prefix shape.

Fixed

  • A development-build tool no longer installs a release into the VM (#704). The tree's version now carries a .devN marker between releases, and a msks-setup-macos-host run without --wheel refuses by name on the marker with the build-a-wheel remedy. Previously a tree whose version number matched a release silently installed that release's daemon, which rejected the config the same run wrote.

  • A failed staging-agent bind reports the real error (#710). The ssh/rsync staging agent's cleanup closed a server a failed bind never built, so a bind refusal (a socket path past the AF_UNIX limit, an unwritable temp dir) printed an UnboundLocalError about the cleanup instead of the error itself. The cleanup now runs only for a server that was built, and the original refusal reaches the operator.

  • msks-setup-macos-host --uninstall names a missing limactl (#701). On a Mac where limactl is not on PATH, the uninstall arm died with a raw FileNotFoundError traceback; it now prints the install-Lima remedy on stderr and exits 1, the same named-refusal shape the setup and --check paths answer with.

  • ssh re-pins host keys when a workspace's disks are recreated (#697). The daemon records a disk epoch on each workspace and advances it whenever it recreates the root overlay under a live row (a healed loss, a factory reset) — the guest regenerates its ssh host keys with the fresh overlay. The identity answer carries the epoch, the client rotates its per-workspace host-key cache on a change and prints a notice naming the recreation, and every invocation — msks ssh <name> and the TUI's shell action alike — pins under the workspace's id, so a regenerated key no longer refuses from one entry point while the other silently re-pins.

  • msks-preflight goes green on a Mac dev host (#691). The remaining darwin failures in the gated suite are closed: the Linux-only surfaces skip with their platform reasons named, the host-independent paths they hid run on every host, and the suite's coverage bar adapts off Linux where those surfaces cannot execute. A Mac pre-flight runs the full gate stack green in about a minute; Linux CI is unchanged.

  • The gated suite terminates on a Mac dev host (#690). The microvm driver's VMM and virtiofsd identity checks read the process command line through procfs only, so on a Mac every kill, shutdown, and launch sweep read its own spawned daemons as foreign — the signal never fired, the test waited forever, and a pre-flight run wedged past half an hour. The read now falls back to ps(1) on darwin (Linux keeps the procfs path), the stale-socket probe sweeps a non-socket path that darwin's connect() answers ENOTSOCK where Linux answers ECONNREFUSED, and the Linux-only pieces (the NFQUEUE binding's import, a SIGSTOP escalation path the darwin event loop cannot schedule) skip with their reasons named. The suite drops from a hang to about a minute on an Apple-Silicon dev host.

  • Guest images retry the home-volume mount on every boot (#688). A restarted workspace that lost the udev race — the volume's by-label symlink landing after the mount unit's 30s device timeout — came up with /home unmounted indefinitely (the first-boot retry that rides the identity seed never runs on a later boot). Every guest image now ships a udev rule that asks systemd for the mount when the labeled disk appears, so /home mounts moments after the label lands, on every boot a rebuilt image serves; existing images keep the seed's retry for first boots.

  • The seed retries a dependency-failed home-volume mount (#686). On a boot slow enough that udev's by-label symlink lands after the fstab unit's 30s device timeout, home.mount fails as a dependency and systemd never retries it — the volume disk is present, and first boot paid the seed's whole 20-minute wait (observed on the macOS nested stack). The seed's wait now asks systemd for the start itself when the volume disk is present: paced at 30s, best-effort, and only the start verb. The retry mount took under a second on every reproduction.

  • doctor's apple-kvm row gates on the NV-mode marker (#664). The warning row now requires an NV-mode marker — the kvm-arm.mode=nested command line, sysfs's kvm mode parameter, or the kernel log's NV init line — beside the Apple MIDR, so a Linux VM under macOS's Virtualization.framework, whose silicon reads as Apple but whose nested KVM boots guests to ssh, runs the report clean; Apple-silicon hosts running KVM in NV mode keep the warning. See docs/doctor.md.

  • First boot seeds the login user onto the /home volume instead of under it (#662). The first-boot seeding script waits for the persistent /home volume's mount before writing anything under /home: on a host whose first boot is slow enough that the volume's first fsck+mount finishes after the seeding, the seeded homes and their authorized_keys used to land on the root filesystem and be buried when the volume mounted — the login user held ssh for the first minutes of first boot and lost it the moment the mount completed. A boot with no volume attached stands the wait down after a minute and seeds under the root filesystem's /home; a boot whose volume is attached but never completes its mount holds the wait's full 20 minutes first, then seeds the same way.

  • The devenv shell opens on macOS hosts (#665). The dev shell evaluates and opens on aarch64-darwin: the Linux-only VMM toolchain (cloud-hypervisor and the netfilter family) joins the profile only on Linux, while the msks client, the Python/uv toolchain, and the lint gates are available on every host. A Mac checkout can now drive a remote daemon with MSKSC_URL straight from devenv shell. Linux shells carry the same package set as before, unchanged.

  • arm64 workspaces now power off on msks stop (#649). The arm64 guest images' runtime module tree ships gpio_keys, the input driver for cloud-hypervisor's device-tree power button, so the graceful-shutdown press reaches logind's power-key handling and the guest powers off within the deadline. The x86 ACPI button path is unchanged, and msks image check's acpi-shutdown row now passes on arm64 hosts.

  • Release image channel (#659). A version release now carries the previous release's images.json from the moment it is created, so image imports keep working — pulling the previous images — while the release's guest images build (up to hours); before, the manifest fetch failed during that window. The manifest is replaced when the new images finish publishing.

[v0.5] - 2026-10-28

Changed

  • virtiofsd 1.13.0 floor for host mounts (#655). Host mounts need virtiofsd 1.13.0 or newer: the uid/gid translation and read-only enforcement the shares run on landed upstream there, and older builds refuse the daemon's arguments at start. msksd doctor now reads the version (a below-floor binary warns naming the gap and the MSKSD_VIRTIOFSD override — Linux Mint 22 ships 1.10.0), and a mount start on a too-old binary fails with the same named requirement instead of an "exited at start" log tail. See docs/mounts.md.

Fixed

  • Host share mounts: git loose objects no longer fail with EACCES (#657). Creating a file without owner-write bits and writing through the same open file descriptor (git's loose-object path) failed on every virtiofs share. The share's xattr remapping now denies security.capability at the map and leaves the name unmapped, keeping file-capability clearing with the host kernel — and, whatever the daemon's privilege, a guest can neither set nor read the attribute through a share. Applies at each workspace start.

[v0.4] - 2026-10-27

Added

  • Fedora 44 guest image (#651). A third catalog image family builds beside Debian and NixOS: msks-build-guest fedora (and the -x86_64/-aarch64 flavors) produces the fedora:44 archive from Fedora's pinned Generic cloud image, with the same agent toolchain, console, mounts, and egress contract the Debian image ships. The guest boots with SELinux disabled — the image build reconstructs the filesystem without SELinux labels, and the workspace's isolation boundary is the VM itself. See docs/images.md.
  • Apple Silicon host warning in doctor (#649). msksd doctor names the Asahi-class kernel limitation where it exists — cpu0's MIDR names Apple — as a warning row: this kernel's KVM does not yet deliver guest interrupts, so workspaces start and their kernels boot, then stall before ssh answers; everything short of a guest boot works. The installation chapter documents the same host class, and the row stays off x86_64 and non-Apple arm64 hosts.
  • Per-arch channel images and arch-checked imports (#638). The release channel publishes one row per flavor and arch: images.json carries an arch field beside the kernel and size, built by per-arch legs and merged into one manifest. msks image import compares the image's declared arch against the host's own machine and refuses a mismatch naming both sides — a foreign-arch kernel would hang wordlessly at boot. An image without the field imports as before, and msks image check and image info print the declared arch.
  • arm64 guest images (#637). The guest pipeline builds for x86_64 and aarch64: msks-build-guest follows the building host (the -x86_64/-aarch64 flavors pin it), the arm64 guests run 16K-page kernels (Debian's own arm64-16k flavour; a kernel patch on the NixOS side) so nested KVM initializes on 16K hosts like the Asahi class, and image.json declares its arch — images without the field import as before. See docs/images.md.
  • msks-setup-fedora-host (#635). The Fedora deployment-host setup — msks-setup-debian-host's phases over dnf: the capability wrapper, the routing kernel settings, the dependency packages, the kvm grant, the systemd user service, and --uninstall's mirror. sudo "$(command -v msks-setup-fedora-host)" prepares a stock Fedora host, the Asahi remix included, end to end. See docs/installation.md.
  • The dev daemon finds its wrapper and uplink (#635). msks-dev resolves the setup tool's /usr/local/bin/msks-caps and the host's default-route interface by itself, so a bare devenv processes up starts on a host NixOS does not manage — DEV_CAPS and DEV_UPLINK remain the overrides.
  • Host share mounts (#620). A workspace can mount host directory trees into its guest: the daemon's config names the shares (mount_shares, reloadable on SIGHUP), a workspace mounts them by share, guest path, and mode (msks create --mount, msks mount/umount, or the TUI's mounts editor), and changes apply at the workspace's next start. Files present in the guest as the workspace's own user. See docs/mounts.md.
  • Guest timezone from the host (#633). A create captures the host machine's timezone and the guest's clock follows it — the seed's first-boot script sets it, with no operator action. The capture is create-time and immutable: a host timezone change later affects only new creates, and a host on UTC leaves the guest on the image's own default. See docs/images.md (first-boot provisioning).

[v0.3] - 2026-10-24

  • The guest images' pi coding agent moves to 1.4.0, and a default ~/.pi/agent/settings.json now ships for every user: thinking blocks stay out of the transcript and pi's install telemetry beacon stays off (#616). The project adopts the MIT license (#612), and the docs split the egress consent chapter out of guest networking.

[v0.2] - 2026-10-23

  • Guest image updates, docs changes.

[v0.1] - 2026-10-18

Added

  • The NixOS workspace guest image (#250). A second catalog image built from the devenv-pinned nixpkgs with msks-build-guest nixos (into .devenv/state/guest-nixos; GUEST_NIXOS_DIR relocates it), registering under the nixos name beside debian. The archive carries the same containerDisk contract and the same declared capabilities — cloud-init, the prelude-v1 console — so the daemon serves it with no new machinery and nothing keyed off the image's name. See docs/images.md.
  • msks egress tui (#195). The consent decider TUI: a textual app that registers as the workspace's decider, shows held requests with countdowns (a/d verdicts, A/D duration picker), and adds a rules screen with revocation — over the frames and endpoints #69 serves, reconnecting with backoff (reset after each healthy connection). Countdowns follow the deadline the daemon carries on each request frame, and a registration the daemon rejects (an unknown workspace) exits with the reason instead of waiting. The watch/decide/revoke subcommands remain the scripting surface. See docs/cli.md.
  • Placeholder secret store, msks secret commands (#198). Mint a sentinel for a workspace (msks secret mint <ws> --name <label> --dest <hosts> --secret-file <path>|-) and the real secret lives in a SecretSpec-backed store, provider as a setting (secret_store_provider: file default, age for at-rest encryption, awssm, bws), never in the workspace; ls, renew --ttl, revoke, and check round out the surface. Secrets ride stdin or a file, never argv; each lifecycle event (mint, revoke, expiry) lands in the audit table. See docs/secrets.md, the key reference, and cli.md.
  • msks rsync (#190). Zero-setup file copies over the forward: msks rsync <ws> -- -av ./site/ root@:/root/site/ boots the workspace when the daemon reports it as not running, stages the ssh identity in memory the way msks ssh does, and runs the host rsync through a forward the copy opens itself — push and pull, with the identity in memory only and no key file written. A path with an empty host (:src/, root@:/root/site/) targets the workspace the command names, its login user riding the paths' own user@ spelling; a --pubkey workspace exits naming the recovery for its private half. See docs/cli.md.
  • Egress consent (#69). Workspaces pick a consent posture at create (--egress-mode allow|static|interactive plus repeatable --allow specs; allow is the default and preserves the current unrestricted egress). static resolves and reaches only the allowlist (off-list names answer NXDOMAIN and are recorded); interactive holds each new connection's first packet in the deployment host's kernel until a decider answers over the events channel — msks egress watch/decide/revoke/rules/requests drive the whole verdict lifecycle (once/5m/15m/tilrestart/forever), with revocation also dropping a destination's live connections. Ports 53/853 drop toward anything but the daemon's resolver (the naming layer), verdicts pin names not just addresses, and the queue fails closed (msksd down or a full queue drops). New settings MSKSD_EGRESS_MODE, MSKSD_EGRESS_CONSENT_TIMEOUT_S, MSKSD_EGRESS_CONSENT_RATE_LIMIT, MSKSD_EGRESS_CONSENT_RETENTION_DAYS, MSKSD_EGRESS_CONSENT_ROW_CAP, MSKSD_EGRESS_QUEUE_BASE, MSKSD_CONNTRACK_TOOL, and MSKSD_AUDIT_HMAC_KEY (consent rows carry HMAC tags when it is set). See docs/egress-consent.md and docs/cli.md.

  • msks storage image import times (#186). Each catalog row in the image cost table shows when the archive came in (local time to the minute) and the GET /api/v1/storage image entries carry the time beside the hash, so entries sharing a reference (a rebuilt image imported again) read as distinct; the table orders them oldest-first. Re-importing the same archive refreshes the time. See docs/storage.md.

  • msks resize and POST /api/v1/workspaces/{id}/resize (#184). A stopped workspace's disk sizes move after create: the /home volume grows or shrinks (e2fsck quiets the journal, resize2fs moves the filesystem, a shrink below used blocks answers a named 409), and the root overlay grows with the next start's cloud-init filling the larger device. The row, msks ls --json, and msks storage report the new ceiling immediately, and a completed resize announces workspace.resized. New settings MSKSD_RESIZE2FS and MSKSD_E2FSCK name the tools.
  • msks storage and GET /api/v1/storage (#184). The daemon reports the state-disk budget (used/free with a named pressure condition), each workspace's cost beside its size ceiling, and each catalog image's cost. The watcher publishes a storage.pressure event on every change with a named log line, and workspace creates, image imports, and home-volume imports answer 507 below MSKSD_STORAGE_FLOOR_MIB (default 512 MiB; the warn line sits at MSKSD_STORAGE_WARN_PCT, default 90), with imports sized when their bytes are knowable (the archive counted twice; a Content-Length body counted once), instead of filling the disk until guests see I/O errors.
  • msks ssh -- -A forwards the operator's agent (#174). A command-line -A (or -o ForwardAgent=yes) now names the agent behind SSH_AUTH_SOCK explicitly, so ssh-add -l and git push inside the workspace use the operator's keys — no Host msks-* ssh-config block needed. The workspace identity stays the session's authentication credential; the guest receives the operator's keys and nothing else. An explicit -o ForwardAgent=<path> keeps its own socket (it wins over every flag and value, as with stock ssh), and a line stock ssh resolves to off — including -A -a — passes through untouched. The explicit form needs an OpenSSH client of 8.2 or newer (the previous behavior forwards the session agent on older clients).

  • Console challenge-response (#123, first half). The guest console demands a signature the daemon cannot produce: workspaces seeded with an identity now also plant an allowed_signers trust store, and each console connection carries a fresh nonce the client must sign (SSHSIG, msks-console namespace) before any shell — verified by the guest's own ssh-keygen, refused closed otherwise. msks console answers transparently with the same key as ssh (escrow, client data root, or the operator's ssh-agent, hardware keys included — msks never reads an agent-held half). Pre-change guests serve no challenge. Enrollment, minted-key rotation, and the audited re-enable follow in the second half.

  • Operator-supplied ssh key at create (#132). msks create --pubkey FILE (or - on stdin) builds the workspace around a public key the operator already owns, at any well-formed key type: the daemon accepts the supplied line as-is (shape-checked, re-annotated, seeded like any identity) and holds no private half. Minted keys keep the FIPS-approvable type set; login uses the operator's own key, and msks ssh's recovery text names it. See docs/networking.md.
  • Client-minted workspace identity as the create default (#121). msks create mints the workspace's ssh keypair on the client and sends the public half only: the daemon stores and seeds that half exactly like its own minted one and holds no private half — msks key answers the public line, and msks ssh serves the private half from the client data root (~/.local/share/msks/<id>/identity, mode 0600). --daemon-mint keeps the daemon-minted escrow mode (#111); --key-type selects the type (ed25519 default, #138). See docs/networking.md.
  • git-out through egress with a forwarded agent (#81). The dogfood loop's outbound half is proven end to end by a new opt-in root smoke, test_local_egress_git_out (it runs in the KVM workflow's egress step): the workspace installs git from Debian's mirrors and fetches an unrelated HTTPS host through the NAT'd uplink, then pushes a commit to a scratch git server on the host through a test-widened input pin, authenticating only with an operator key that rode the forward as a forwarded agent — no credential in the image or the seed. The workflow and its ControlMaster caveat are in docs/networking.md.
  • msks ssh (#112). One command logs into a workspace with stock ssh over the forward websocket: it boots the workspace if needed, fetches the minted identity over the authenticated API, and serves the private half from a transient in-process ssh-agent — ssh names the identity by its public half and signs through the socket, so the key never becomes a file on the client. Logs in as the msks workspace user by default (-l root for recovery); per-workspace known_hosts under accept-new. See docs/cli.md and the alias workflow in docs/networking.md.
  • Home-volume export/import (#80). A workspace's /home volume now moves through the daemon's authenticated listener as byte streams: GET/PUT /api/v1/workspaces/{id}/home stream the volume file out and replace it from an uploaded ext4 image, with msks home export / msks home import on top (- speaks stdio, so the bytes compose with gzip or ssh). The workspace must be stopped (unknown refuses too — the VM may be live), moves serialize against boots and deletes per workspace (waiters answer a named 409 after MSKSD_MOVE_WAIT_TIMEOUT_S, and the daemon re-checks the live VMM under the lock), an upload that fails the ext4 check or dies mid-body leaves the existing volume in place, all-zero windows come back sparse, and each completed move publishes a home.exported / home.imported event. See storage and the CLI.
  • Minted workspace identity and msks key (#111). msksd mints a per-workspace ssh keypair at create — Ed25519 by default (#115, #138), with the type configurable via MSKSD_SSH_KEY_TYPE — and stores both halves with the workspace's state. The public half is planted into authorized_keys for root and the msks workspace user through the first-boot seed (composed with any user_data payload), so a fresh workspace accepts ssh with no manual key steps. msks key <ws> fetches the identity over the token-gated API; --private prints the private half, --out writes it mode 0600. The halves persist across daemon restarts and workspace stop/start.
  • Guest sshd and rsync (#110). The workspace image serves TCP on its forward path: sshd (Debian's own package, enabled) listens on all interfaces behind a boot-time unit that waits for the NIC's address (15s ceiling), with key-only login (PasswordAuthentication no, PermitRootLogin prohibit-password) pinned by a config dropin, and rsync ships in the image. Host keys are generated on first boot into the persistent root overlay, so they survive stop/start — the recorded known_hosts entry keeps matching. The egress input rules admit the conntrack-established replies to the daemon-originated forward dial; guest-initiated connections arrive state NEW and still drop. See networking.
  • msks forward and the forward websocket (#109). A workspace's guest TCP ports reach the client through a new authenticated endpoint, WS /api/v1/workspaces/{id}/forward/{port}: the daemon dials the workspace's deterministic tap address (retrying through the DHCP/service bring-up race) and pumps raw bytes both ways — one websocket per guest TCP connection, no framing, with forward.opened/forward.closed events on the events channel. The client command bridges stdio (the ssh ProxyCommand shape) or, with --local PORT, a loopback listener where every accepted connection gets its own forward. The token authenticates through the Authorization header — the REST surface's Bearer form, kept out of URLs and their logs; a workspace without egress is refused with the reason naming the missing NIC. See docs/cli.md.

  • The dev-workspace bootstrap seed (#77). scripts/dev-workspace.sh, passed to msks create --user-data, bootstraps an msks development environment inside a workspace over its own egress NIC at first boot: uv (with its own Python 3.14), the repo checkout, and uv sync — the venv the unit-tests invocation runs from — all on the persistent root overlay; stop/ start keeps it, a factory reset re-provisions, re-running is a no-op. Proved end to end by the TEST_EGRESS=1 root smokes.

  • msks shell --user and the console identity prelude (#63). The vsock console now serves shells as root or the image's workspace user: a guest-side helper negotiates the identity, the client terminal's size, and its TERM value in-band on each connection, and the listener accepts host-originated connections only (guest-local peers are refused). A workspace bound to an image whose record cannot be read is refused with close code 4501 — the console never silently downgrades to a raw root shell. Images declare console_protocol: prelude-v1 and console_users in their manifest; older images keep today's raw root shell through the daemon's legacy path. The helper is a Rust crate under src/console-helper, gated at 100% line and branch coverage (.github/workflows/rust.yml).

  • YAML config file for msksd (#46). msksd reads msksd.yaml — --config <path> for an explicit file, --config=none for env-vars-only, and a bare msksd resolves $MSKSD_CONFIG_DIR/msksd.yaml (default ~/.config/msksd/msksd.yaml), generating a commented template on first run. Precedence is MSKSD_* env vars > file > defaults; a config key is its variable minus the prefix, lowercased (MSKSD_PORT → port, MSKSD_EGRESS_SUBNET → egress_subnet — flat, klangkd's convention, derived by one rule so the spellings cannot drift), keys take native YAML scalars, and unknown keys, duplicate keys, or invalid values fail at startup. SIGHUP re-reads the file into the live settings without a restart. The deployment-host module (#229) generates the file for the service with the build's store paths, and a bare alembic run resolves the database URL from the default file when present. See docs/config.md for the key-by-key reference.
  • msks-preflight (#43). One command delivers the Python-side pre-commit gates' complete feedback before the first commit attempt: every ruff and deferred-import finding, every xenon offender, the jscpd report, and — when sources under src/msks/ changed — the gated suite run followed by every missing coverage line and branch arc for the changed files (scripts/covgaps.py). AGENTS.md makes it part of the loop: fix everything it names in one editing pass, re-run, then commit. The xenon and jscpd gates now also grade untracked-but-present files, so a new file grades from the moment it exists.

  • Doubled-escape and chunked input in msks shell (#36). The client reads stdin in 4,096-byte chunks and sends one websocket frame per chunk, so a large paste lands a few frames instead of one TLS frame per byte. Ctrl-] Ctrl-] — the second press within 50 ms, or both bytes in one chunk — sends one literal Ctrl-] (0x1d) to the guest; a single Ctrl-] still detaches, and bytes typed before the escape are delivered before the session closes. See docs/cli.md.

  • Per-workspace user_data provisioning (#41). A workspace created with user_data (API field; msks create --user-data FILE, - for stdin, ≤64 Ki characters) runs the payload on its first boot through cloud-init: the daemon builds a cidata-labeled iso9660 seed disk (NoCloud's own format, MSKSD_MKISOFS), attaches it read-only, stores it mode 0600, and deletes it with the workspace; both #! scripts and cloud-config documents run. The payload is create-time and immutable (PUT/PATCH answer a named 405). See docs/images.md.

  • Pre-commit hook suite (#72). Eleven hooks join the commit-time suite: the deferred-imports checker (scripts/check_deferred_imports.py, ported from klangk — imports live at module scope, with # allow-deferred-import as the escape hatch), shfmt, shellcheck, check-executables-have-shebangs, markdownlint, actionlint, trufflehog, nixfmt, check-toml, yamllint, and prettier. devenv shell writes a generated .prettierignore; the lint rules live in devenv.nix; CI's lint workflow runs the whole suite (pre-commit run --all-files). trufflehog scans staged file contents and fails only on credentials that verify live (the stock git-history variant scans no commits at commit time). The tree was formatted once to the new gates (docs, workflows, Nix, shell scripts).

  • msks image catalog commands (#65). The CLI now manages the image catalog without curl: msks image ls prints one line per image (reference, short hash, default designation, kernel facts; --json for the raw listing), msks image import <path> registers a daemon-side archive and prints the imported reference (the help states the daemon reads the path — nothing is uploaded), msks image rm <ref> removes by any catalog reference form (name:version, bare name, name@hash, full hash, or a unique hash prefix) and surfaces the 409 naming a workspace that boots the image, and msks image info <ref> prints one image's full record. See docs/cli.md.

  • msks-jscpd token-clone gate (#71). The jscpd scanner (5.0.16, a pinned per-platform prebuilt binary fetched from npm — linux x64/arm64 gnu and macOS arm64/x64; unsupported platforms fail at eval time — ported from klangk #2904) joins the dev environment as both a report (msks-jscpd) and a pre-commit gate: scripts/jscpd-gate.sh is the single definition of the invocation (tracked sources in src/msks/msks, --min-tokens 70), shared with the msks-xenon pattern. A commit fails when any exact clone of ≥ 70 tokens exists among those sources; the backend baselines clean (0 clones), so the gate starts from zero.

  • Workspace egress networking, on by default (#52). Workspaces are networked from creation: each boots with a virtio-net NIC onto a per-VM tap on the deployment host, takes its address, gateway, and the daemon's resolver over DHCP from a per-workspace /30 of MSKSD_EGRESS_SUBNET (the slice recorded on the row, so the address is stable across restarts), resolves through the daemon's UDP forwarder, and NATs out the host uplink through a per-VM nftables table (forward: the guest's source out the uplink and established replies back, all else drops; input: DHCP and the resolver only, so guests cannot reach the daemon's own services) — create with "egress": false (msks create ws --no-egress) to boot NIC-less. The plumbing arms while MSKSD_EGRESS_ENABLED=true and the daemon holds CAP_NET_ADMIN; the host's forwarding comes from the msks module (#229). The guest overlay ships the DHCP client (systemd-networkd unit + resolved stub). Consent gating also arrives with #69. See docs/networking.md.

  • Client CLI: msks stop, msks rm, and the workspace listing named msks ls (#66). msks stop <id> powers a running workspace off — a graceful, deadline-bounded guest shutdown — and prints <id> stopped; msks rm <id>… deletes one or more workspaces (stopped or running) together with their persistent root overlay and /home volume, printing one <id> deleted line per id. The workspace listing command is msks ls (the #59 behavior under its final name, matching msks image ls), completing the lifecycle set: ls / create / start / stop / rm / shell. Missing workspaces (404), workspaces recorded on another host (409), and missed shutdown deadlines (503) print one readable line and exit non-zero. See docs/cli.md.

  • Client CLI: msks ls, msks create, and msks start (#59). msks ls prints one line per workspace (id, status, image hash, host; --json for a machine-readable document), and msks create POSTs the API's create body — --image for a catalog reference, --cpus/--mem-mib/--root-mib/--home-mib for sizing, explicit --kernel/--rootfs to bypass the catalog — printing the new workspace's id; --start also boots it, and msks start <id> boots an existing workspace later. msks shell now boots a not-running workspace itself before attaching (notices on stderr). All commands use the MSKSC_URL/MSKSC_TOKEN/MSKSC_CAFILE conventions from msks shell; connection failures, timeouts, and API or validation errors print one readable line, not a traceback. See docs/cli.md for the command and environment reference.

  • Per-workspace persistent state (#14). Every workspace owns two persistent artifacts created with it: a root overlay (qcow2, copy-on-write over its image — package installs and root edits persist across stop/start) and a /home volume (ext4, labeled msks-home, mounted at /home). Both survive stop/start and are removed with delete; the new POST /api/v1/workspaces/{id}/reset drops only the overlay; sizes come from the create request (root_mib/home_mib) with MSKSD_ROOT_MIB/MSKSD_HOME_MIB defaults, and the workspace row records the owning host, so a start/stop/reset/delete from another host answers a named 409. The shipped image boots its root read-write through the overlay. See docs/storage.md for the layout, lifecycle, and env-var reference.

  • Workspace boot performance (#37). Start → interactive shell is now ~3.1s p50 on the reference host (was 6.7s; goal < 5s), measured by the new scripts/perf-boot.py harness, which also reports the VMM's peak resident set (165–185 MiB for a 1024 MiB guest). The shipped image boots Debian's cloud kernel (ext4/virtio-pci built in) with a msks-built minimal initramfs (one module, ~0.05s) and starts the vsock console before the boot completes; AppArmor, networkd, timesyncd, resolved, unattended-upgrades, and e2scrub units are off the boot. docs/boot-speed.md documents the breakdown and the measurement.

  • Workspace image guide (#38). docs/images.md documents the image contract (container-image tar, containerDisk layout, disk/image.json schema 2), building an image (the shipped builder and a from-scratch outline), registering via POST /api/v1/images (default designation, storage cost, removal rules), and referencing images from workspace create (name:version, bare name, name@hash, bare hash).

  • Workspace image catalog with containerDisk images (#40). The canonical image is now a container-image tar (podman load compatible) in the containerDisk convention (workspace-<name>-<version>.tar: boot/vmlinuz, boot/initrd.img, disk/rootfs.ext4, disk/image.json schema 2) — importable with stock tools. GET/POST /api/v1/images list and import (per-hash boot-file cache; workspace launches never unpack); workspace create accepts "image": "name:version" (or bare name, or hash) and falls back to the designated default, so a bare create works on a fresh deployment — MSKSD_DEFAULT_IMAGE imports and designates at first boot. Explicit kernel/rootfs fields still win.

  • Workspace shell (#21). msks shell <workspace-id> gives an interactive shell inside a running workspace microvm, from any host that can reach the daemon: the client speaks the authenticated /api/v1/workspaces/{id}/console websocket (TLS + token, Ctrl-] detach, raw tty mode), and the daemon proxies it over virtio-vsock — the VM's vsock unix socket after a CONNECT <port> handshake — into a per-connection busybox ash on a pty served by static socat in the guest (root shell today, per #5's guest userland; MSKSC_URL/MSKSC_TOKEN/MSKSC_CAFILE configure the client). The guest assets gained the vsock module, /dev/vsock creation, devpts/ptmx setup, and socat; the daemon retries the console handshake across the guest's post-boot bring-up window.

Breaking

  • Dev state moved under .devenv/state/ (#156). The repo-root state dirs — .guest/, .msksd/ — now live at .devenv/state/{guest,msksd}/, each relocatable via GUEST_DIR or MSKSD_STATE_DIR (the daemon's own setting; use an absolute value so the tasks and the daemon land in the same place). Existing state does not migrate: move the directory you want to keep or rebuild (devenv processes up -d, msks-dev-ready), then delete the old dirs — they are no longer gitignored. The devenv shell also prunes its own stale one-shot wrappers (.devenv/shell-*.sh older than an hour) automatically.

Changed

  • The NixOS deployment host (#229). msks ships as a flake whose NixOS module runs msksd and cloud-hypervisor directly on a NixOS deployment host; workspaces boot under cloud-hypervisor on the host's /dev/kvm — one VM boundary between a workspace and the metal — and egress consent is enforced in the host kernel (per-VM taps, the nftables/NFQUEUE consent path, DHCP, DNS, and NAT arrive with the module). The appliance layer (msks-appliance-*, the #220 update channel, the state-disk machinery) and the Kubernetes deployment target are removed: updates and rollback come from rebuilding the host's NixOS configuration. The roadmap of the time (docs/ROADMAP.md, since removed) carried the plan.
  • Plain scripts instead of dependency-free devenv tasks (#166). Every msks devenv task without after/before ordering is now a plain script on the devenv shell's PATH (msks-build-guest, msks-preflight, and the rest) — run it from a devenv shell directly or devenv --quiet -O dotenv.enable:bool false shell -- <name> from outside; msks:uv-sync remains the one task. The pinned nixpkgs source is exported as GUEST_NIXPKGS to every devenv context, and the build and lifecycle scripts print their start and outcome.

  • Minted identity keys default to ed25519 (#138). The client mint (--key-type, msks create) and the daemon mint (ssh_key_type / MSKSD_SSH_KEY_TYPE) now mint Ed25519 keys — FIPS-approvable (FIPS 186-5) and accepted by ssh clients restricted to the common ssh-ed25519,ssh-rsa set, so msks ssh into a default-created workspace needs no algorithm passthrough. --key-type ecdsa (the former default) and --key-type rsa remain available for deployments whose validated crypto module predates EdDSA. See docs/networking.md.

  • msks console (#117). The interactive workspace command is renamed from msks shell to msks console, matching the /api/v1/workspaces/{id}/console websocket it speaks. A hard rename: msks shell now exits with the standard unknown-command error. See docs/cli.md.

  • msksd runs as a non-root service user (#101). The msks module's systemd unit executes the daemon as a dedicated msksd system user holding exactly two ambient capabilities — CAP_NET_ADMIN (taps, nftables, and the VMM's tap opens) and CAP_NET_BIND_SERVICE (DHCP 67, DNS 53) — and nothing in its process tree runs as uid 0; /dev/kvm reaches it through the kvm group, and the state directory is service-user-owned (/var/lib/msksd). net.ipv4.ip_forward=1 is a host setting the module carries when egress is enabled: msksd verifies it and refuses egress with the cause naming net.ipv4.ip_forward when it reads 0. See docs/networking.md.

  • The workspace guest boots Debian's generic kernel (#96). The workspace image archive shrinks ~19 MiB compressed (147.8 → 128.7 MiB xz) as Debian's full cloud module tree leaves in favor of the twelve-file runtime closure the build derives from modprobe metadata (and pins by comparing the full tree's closure against the shipped tree's at build time). Boot speed holds (p50 start→prompt 3.24 s against the cloud flavor's 2.95–3.26 s host spread; goal < 5 s), and guest memory at first prompt is unchanged (~140 MiB); numbers recorded in docs/boot-speed.md.

  • The workspace image ships cloud-init (#41). The image is built from Debian's genericcloud cloud image instead of the cloud-init-free nocloud variant: cloud-init and its python3 runtime arrive with the base (~130M larger), and two dropins pin NoCloud as the only datasource and keep cloud-init off the guest's networking. The interactive boot budget is unchanged (vsock shell ~3.0s p50); existing workspaces keep the images they were created with — recreate workspaces to move them onto the new image.

  • Workspace stop is now a clean poweroff (#14). The local backend's stop presses the ACPI power button (vm.power-button) and the guest's systemd-logind runs a full shutdown before the VMM exits. The endpoint stop used before was cloud-hypervisor v52's hard stop: the guest was never notified, and with persistent disks every stop dropped the writes still sitting in the guest's page cache.

  • The workspace guest is Debian 13 trixie (#30). The rootfs comes from Debian's official nocloud cloud image (pinned by dated URL + sha512): systemd as PID 1, apt (present but inert while the root is read-only), and Debian's own kernel direct-booted. msks-build-guest, the manifest contract, and the smoke path are unchanged; the busybox guest is gone. See the README for build details, timings, and the setuid/ownership notes.

  • Local-driver and TLS hardening fixes from the first e2e (#10). The local driver now honors the absent-VM contract on stop/kill (a stale api socket behind a dead VMM reported ECONNREFUSED and delete-after-stop 500'd), the rootfs disk is declared readonly + image_type: Raw in vm.create (v52's autodetection otherwise disables sector-0 writes, and O_RDWR on the read-only store share fails), and tls._write loops os.write (a short write through virtio-backed storage left a truncated CA key).

  • The msksd daemon and its /api/v1 API (#8). msksd now serves versioned endpoints over one HTTPS+WSS listener — public health, hashed bearer-token auth with revocation (MSKSD_BOOTSTRAP_TOKEN seeds the first credential), workspace create/list/status/start/stop/delete driving cloud-hypervisor through the microvm seam, and a websocket event channel for lifecycle transitions. TLS is operator-provided (MSKSD_TLS_CERT/KEY) or a self-signed CA generated on first run whose fingerprint is logged for trust-on-first-use pinning; --no-tls serves plain HTTP for development. State lives in an SQLite database under the state dir (MSKSD_STATE_DIR), managed by Alembic migrations.

  • Nix-built guest assets (msks-build-guest, msks-demo-vm). The devenv now produces everything needed to boot a microvm — kernel, initrd, read-only ext4 rootfs into the guest state dir (.devenv/state/guest/) — from the nixpkgs revision devenv itself pins, on any Linux host with nix; the manual-download flow is gone. Boot tests pick the built artifacts up automatically (explicit TEST_VMLINUX/TEST_ROOTFS/TEST_INITRD variables keep precedence) and skip themselves when the guest was never built or /dev/kvm is unusable. msks-demo-vm boots one interactive VM from the artifacts with ch-remote ready (#5).

  • msksd --reload (development, #144). The daemon gains a development flag that watches the msks package tree it runs from and restarts the process when it changes.

Fixed

  • Console websocket closes protocol-clean (#217). The console bridge now ends every session with a close frame instead of dropping the TCP: a guest that refuses the console signature closes with 4403 and the guest's MSKS ERR text as the reason (watched until the helper's AUTH OK verdict, so a logged ERR line in later shell output is not one), a guest stream that ends (shell exit) closes with 1000, and only a client disconnect ends without one. Clients can tell refusal apart from transport death.

  • The nix-built msks package ships textual (#203). The package set shipped without textual: the msks wheel build failed its runtime dependency check, and a daemon built from it crashed at import time. nix/textual-pkg.nix builds the pinned 8.2.8 wheel into the closure, and a new test keeps nix/msks-pkg.nix mirroring [project.dependencies] — the next dependency addition fails a unit test instead of the nix build.

  • Guest images keep the base image's file ownership (#179). apt inside a workspace now sees the stock Debian metadata the base image ships: mandb's cache is man:man again (an apt upgrade printed Permission denied on the whole man cache), and every other non-root owner — the shadow-group password files, utmp logs, the systemd-network, dbus, and polkit state directories — round-trips with it. Build-time pins fail the image build when sudo loses its setuid bit or the man cache loses its owner. Workspaces must be recreated to pick up a rebuilt image.

  • Workspace home and the guest's account list (#171). The first-boot identity seed now creates /home/msks owned by the workspace user and populates it from /etc/skel (a home left root-owned is repaired on the next provisioning), and the guest image ships a cloud-init dropin that keeps the Debian cloud image's own default account (debian, with /home/debian and a passwordless-sudo entry) from ever being created — a workspace created from this image carries msks as its only account.

  • msks ssh waits out a just-booted workspace's identity seed (#168). The first msks ssh against a freshly created workspace could exit Permission denied (publickey): the guest's sshd answers before first-boot provisioning has written authorized_keys, and a bare retry succeeds. A session whose pre-flight booted the workspace now probes the login (a throwaway true command, retried up to 30s) before opening the real session, which runs exactly once either way.

  • Workspace sudo works — the guest image keeps the setuid bits (#169). The image build extracts the Debian root unprivileged, which dropped every setuid/setgid bit (sudo, su, mount, …); the msks workspace user hit sudo's refusal — "must be owned by uid 0 and have the setuid bit set". The build now restores the bits from the source image's own inode modes, and the image grants the msks user passwordless sudo (the locked password makes NOPASSWD the only form that can run), so sudo apt update works over a workspace console or ssh. Workspaces created from an older image keep their old backing rootfs until recreated (msks rm, then msks create).

  • Failed starts keep an actionable status (#158). A vm.boot failure reaps the half-created VMM and the watcher's dead-socket probe consults the identity check, so the workspace reports stopped — not the incident's unknown, which a bare pid check satisfied with a recycled pid — and the next msks start retries. The incident's exact shape, recycled-pid trigger included, is pinned by tests, and a stop/start lands a merged fix.

  • Stale workspace sockets no longer break start (#151). A VMM killed without cleanup (host crash, hard stop) left api.sock/vsock.sock behind under vms/<id>/; the next start died binding them and surfaced a misleading unreachable-API/ENOENT 503. The start path now sweeps refused sockets (a live listener is never unlinked), removes the stale ch.pid (a recycled pid after a host reboot no longer blocks starts with "already exists"), and a VMM that dies at spawn names its own cause in the error (the tail of its ch.log) instead of a bare exit code. VMM liveness is identity-checked against /proc/<pid>/cmdline: a pid that is not this workspace's VMM is never signaled, and one owned by another user is named in the error instead of silently reported stopped.

  • Legacy CA crash-loop on leaf remint (#148). A CA minted before the strict-clean change parses but carries no Subject Key Identifier; the leaf remint that a host change triggers read the missing extension and exited, and Restart=always looped the daemon forever (observed live on a long-lived state disk after #146 changed the bind host). Such a CA is now replaced wholesale — a stderr line names the replacement beside the new fingerprint — instead of crash-looping. Current strict-clean pairs keep leaf-only remints (same CA fingerprint).
  • Strict-clean minted TLS certificates (#141). The CA and leaf msksd generates now carry Subject/Authority Key Identifiers and the CA a keyCertSign KeyUsage, so Python 3.14 clients — whose default SSL context enables VERIFY_X509_STRICT — verify the pair instead of rejecting it. Existing state directories keep their current pair; removing the msks-ca*.pem/msks-cert*.pem files under the state dir lets the next start mint the strict-clean replacement.
  • Mid-session console stalls no longer wedge a session open (#103). The guest console helper's byte pump blocked on whichever direction stalled first, so a wedged vsock transport froze the whole session — input echoed but never executed, no output, no close. The pump now moves each direction independently and a stream that accepts no writes for 300 s ends the session; on the daemon side, console_stall_timeout_s (MSKSD_CONSOLE_STALL_TIMEOUT_S, 60 s default, 0 disables) closes a console websocket with 4502 when client input draws no guest bytes for the window — msks shell names the close, and reconnecting opens a fresh session. The echo-off caveat (password prompts) and the helper-window relationship are documented in docs/cli.md and docs/config.md.
  • Egress DHCP/DNS under uvloop-running daemons (#144). The development venv pulls uvloop (via uvicorn[standard]), which does not implement loop.sock_recvfrom/sock_sendto; egress workspaces created by such a daemon never received a DHCP lease or DNS answer. The DHCP and DNS services now receive datagrams through a loop-portable helper and behave identically under asyncio and uvloop. This was also the real cause behind the "guest NIC up but no frames" symptom recorded on #143 — not the container boundary.

  • /home could fail to mount on slow boots (#14). The guest fstab mounted the home volume by label under x-systemd.device-timeout=2s; that clock starts at sysinit job enqueue, before udevd runs, and on a first boot from a fresh overlay (every root read a copy-on-write miss) the udev label probe can exceed what is left of the budget — home.mount then fails for the whole boot (nofail keeps the boot moving and never retries). The device timeout is now 30s, so the mount rides out a slow coldplug; a boot with no volume at all waits the same 30s once and continues.

  • Egress workspaces never took a DHCP lease on fast hosts (#36). The guest reaches multi-user immediately after sysinit, and a networkd that enumerates its NIC while udev is still renaming it (eth0 to ens3) never manages the renamed link — DHCP never runs. The guest image orders networkd after systemd-udev-trigger.service and systemd-udevd.service, so the interface name is final before the first enumeration.

  • ws_url quotes the token and workspace id (#36). The console URL interpolates MSKSC_TOKEN raw into the query string; minted tokens are urlsafe today, so this was theoretical, but any future charset with +, &, =, or % would have mangled it. The token now passes through urllib.parse.quote_plus (a query value), and the workspace id through urllib.parse.quote with no safe characters (a path segment, where + would reach the server literally).

  • Workspace cleanup and image import release their resources (#56). cleanup now stops the workspace's VMM before deleting its directory — tracked or pidfile, like kill — where it previously orphaned the process. Image import closes the container-image archive after unpacking; each import previously left the archive's file handle open until garbage collection.

  • Workspace shell input now echoes (#61). The vsock console's guest pty ran with echo=0,icanon=0 while the client keeps the local tty raw, and the service environment's TERM=dumb left bash's readline off — so typed characters reached nothing that would show them. The pty is now a plain canonical terminal (echo=1,icanon=1) and the unit sets TERM=xterm: line editing and history work, and programs reading stdin directly get normal tty echo. The console ships in the workspace image; a workspace keeps the image it was created with, so delete existing workspaces and the old image from the catalog — new workspaces then bind the new image.
  • devenv shell no longer runs the pre-commit suite at shell entry (#32). A devenv 2.3.x scheduler regression pulled devenv:git-hooks:run into the shell's task graph, so a failing hook (e.g. the xenon complexity gate) aborted shell entry before it opened — with no way to use the shell to fix the failure. The suite still runs on git commit and as the msks-xenon task.