From 35ca88d5178ef75d0bec7d09982116414089cfc1 Mon Sep 17 00:00:00 2001 From: OpenAI Coding Assistant Date: Sun, 6 Sep 2026 12:45:36 -0500 Subject: [PATCH] feat: add project-composable DeepSeek Harness microVMs Share the live project cwd, DSH home and skills read-write while running guest root behind rootless QEMU and Bubblewrap. Reuse project toolchains, expose configurable SSH-forwarded web access, and launch the latest official DSH. Include the project template, operating guide, offline boot and mount tests, and shell checks. --- AGENT-VM.md | 308 +++++++++++++++++++++++++++++ README.md | 9 + agent-vm/boot-test.sh | 55 ++++++ agent-vm/flake.lock | 65 +++++++ agent-vm/flake.nix | 39 ++++ agent-vm/launch.sh | 121 ++++++++++++ agent-vm/lib.nix | 54 ++++++ agent-vm/module.nix | 376 ++++++++++++++++++++++++++++++++++++ agent-vm/tests.nix | 92 +++++++++ flake.nix | 5 + templates/agent/.gitignore | 6 + templates/agent/README.md | 27 +++ templates/agent/flake.nix | 44 +++++ templates/agent/project.nix | 18 ++ 14 files changed, 1219 insertions(+) create mode 100644 AGENT-VM.md create mode 100644 agent-vm/boot-test.sh create mode 100644 agent-vm/flake.lock create mode 100644 agent-vm/flake.nix create mode 100644 agent-vm/launch.sh create mode 100644 agent-vm/lib.nix create mode 100644 agent-vm/module.nix create mode 100644 agent-vm/tests.nix create mode 100644 templates/agent/.gitignore create mode 100644 templates/agent/README.md create mode 100644 templates/agent/flake.nix create mode 100644 templates/agent/project.nix diff --git a/AGENT-VM.md b/AGENT-VM.md new file mode 100644 index 0000000..0d00461 --- /dev/null +++ b/AGENT-VM.md @@ -0,0 +1,308 @@ +# DeepSeek Harness project VMs + +Official **`deepseek-ai/deepseek-harness` (`dsh`)**, not OpenCode. A reusable NixOS +module + flake function + shell launcher. No custom Python control plane, host +service, sudo launcher, or host rebuild is needed. + +## Start a project + +As your normal user, on **x86_64 Linux** with accessible `/dev/kvm` and enabled +unprivileged user namespaces (for Bubblewrap): + +```sh +mkdir -p ~/projects/example && cd ~/projects/example +nix flake init -t path:/etc/nix#agent +# Edit project.nix (tools/env) and flake.nix (RAM/CPU/network). +git init +git add flake.nix project.nix README.md .gitignore +nix flake lock +git add flake.lock +nix develop # ordinary host project shell +nix run .#agent # boots VM; waits and prints a private browser URL +``` + +Run commands **from the project directory**, including in the second terminal: + +```sh +nix run .#agent -- url # print the current token-bearing browser login URL +nix run .#agent -- ssh # root shell, starting in the SAME project cwd +nix run .#agent -- ssh 'id; pwd; rg --version' +nix run .#agent -- ssh 'journalctl -u agent -b --no-pager' +nix run .#agent -- stop # graceful poweroff; Ctrl-C in the launcher also stops it +``` + +Open the printed URL. In a fresh DSH profile, **Choose workspace → add/select the +project's original absolute path**, which exists in the guest. Then +**Settings → Models** to configure DeepSeek, if you haven't already. Existing +credentials/settings are reused. No API key is required merely to boot the UI. +No model inference runs locally: the VM's RAM/CPUs are for tools/builds, not model weights. + +The template input `path:/etc/nix/agent-vm` is a local bootstrap. For collaboration +or guest-side evaluation of the flake, replace it with your accessible Git remote, +e.g. `git+https://git.cyber.ayyalasomayajula.net/marsultor/nixconfig.git?dir=agent-vm&ref=main` +**after these files have actually been committed and published there**. No push is +performed by this setup. Input changes require `nix flake update agent-vm`. + +## Exactly what is shared + +All of these mounts are **read-write**, as requested: + +| Host source, resolved when you launch | Guest path | Includes | +| --- | --- | --- | +| Current working directory, not the flake's store copy | Same absolute cwd; `/workspace` is also an alias | Live source, uncommitted work, `.git`, project `.dsh/skills` and `.agents/skills` | +| `$DSH_HOME`, otherwise `~/.dsh` | `/root/.dsh` | `.credentials.yaml`, `settings.yaml`, `.env`, profiles, plugins, skills, sessions and other DSH state | +| `${DSH_AGENTS_HOME:-~/.agents}/skills` | `/root/.agents/skills` | Shared cross-agent skills only, not the rest of `~/.agents` | + +**Upstream's standard directory is `~/.dsh`, not `~/.config/dsh`.** If you have +chosen an XDG-style location, use the upstream override, consistently for all commands: + +```sh +export DSH_HOME="$HOME/.config/dsh" +nix run .#agent +``` + +Missing directories are created. Existing DSH home must be user-owned and private: +`chmod 700 ~/.dsh` (or your actual `DSH_HOME`); existing credentials must be mode +600 as DSH requires. The launcher never reads the credentials into Nix, copies +them to the store, or replaces your settings/skills with generated content. + +Root inside the guest is **not host root**. QEMU/9p writes as the launching host +user, so newly created project files belong to you. The whole DSH directory is +mounted instead of individual credential files so atomic rename-based saves work. +DSH retains each project's real cwd identity rather than confusing every project's +sessions with a single `/workspace` identity. Do not run host DSH and several +VMs concurrently against the same mutable profile: package-fallback links and +profile changes can race. Separate `DSH_HOME` values provide independent profiles. + +The runner retains a private per-cwd directory under +`${XDG_STATE_HOME:-~/.local/state}/agent-vm/`: dedicated SSH keys, console log, +control socket, and a 4 GiB sparse npm-cache disk. Guest root, guest Nix writes, +processes and other unshared state are otherwise ephemeral. This is **not** a +push/pull/snapshot workflow; edits immediately affect the mounted host files. + +## Resources and Nix composition + +The template uses one ordinary Nix attrset: + +```nix +project = import ./project.nix { inherit pkgs; }; +devShells.${system}.default = pkgs.mkShell { + inherit (project) packages env; +}; +agent = agent-vm.lib.mkAgentVM { + inherit system project; + modules = [ { + microvm.mem = 8192; # MiB + microvm.vcpu = 6; + agentVM.packages = [ pkgs.strace ]; # additional guest-only tool + } ]; +}; +``` + +The function imports the microvm.nix and agent NixOS modules and merges your +modules with them. Normal `imports`, `mkDefault`, `mkForce`, and list merging work. +`apps.${system}.agent = agent.app` exposes `nix run .#agent`; `agent.nixos` exposes +the NixOS configuration for inspection. RAM/CPU/network changes apply on **stop +and restart**, not live. vCPUs are not a host CPU-time quota, and QEMU has some +memory overhead beyond guest RAM. + +The standard guest toolkit includes Git/LFS, ripgrep, fd, Python, Node/npm/pnpm, +jq/yq, curl/wget, common archive tools, Make, pkg-config, ShellCheck, and ordinary +Unix inspection tools. Project versions/tools belong in `project.nix`. + +This deliberately shares **packages and non-secret environment variables**, not +an arbitrary shell's internals. Existing `shellHook`, `inputsFrom`, cross-compilation +setup hooks, library search paths and background dev services are not magically +converted into NixOS configuration. Factor tools/env into the shared attrset; +configure required guest services through `modules`. Do not put credentials in +`project.env`. Build outputs/package definitions can use the same `pkgs` input. + +## Networking: bind address versus VM interface + +DSH intentionally rejects `dsh web --host 0.0.0.0`. We don't patch around its +browser protections: it listens on **guest `127.0.0.1:3080`**, and the shell launcher +publishes an **SSH local forward** on your chosen **host** IPv4 address/port. +DSH's random launch-token → signed-cookie authentication and Host/Origin checks +remain in use. Only SSH is forwarded by QEMU, always on host loopback. + +```nix +agentVM.network = { + hostAddress = "127.0.0.1"; # default: this computer only + sshPort = 2222; + webPort = 3080; +}; +``` + +- **One LAN/VPN interface:** set `hostAddress = "192.168.1.20"` (an IP actually + assigned to this host). Its browser authority is automatically trusted. +- **All IPv4 interfaces:** set `hostAddress = "0.0.0.0"` and + `trustedHosts = [ "192.168.1.20:3080" "laptop.example:3080" ];`. + Use a real address, not `0.0.0.0`, in your browser. The printed local URL can + have its host replaced with one of those authorities. This includes public + interfaces too; it is not shorthand for “LAN only.” +- Multiple project VMs need distinct host SSH/Web ports. +- Binding a host IP controls the **incoming listener**, not outgoing routing or + which NIC reaches DeepSeek. Outgoing traffic follows host routes/VPN policy. +- No host firewall is changed. For LAN access, explicitly allow only the Web port + on the intended host interface in your firewall. Do not open the SSH forward. + +**Use a VPN or a TLS reverse proxy for off-host access.** The forward is encrypted +between host and guest, but browser → host remains HTTP. A token/cookie on an +untrusted network can be stolen. Static assets and third-party plugin routes +may not share DSH's RPC authentication. Never expose this developer-preview +service directly to the public Internet. + +### Optional dedicated TAP interface + +Default user networking needs no administrator setup. If you need your own guest +IP/interface rather than NAT, add: + +```nix +agentVM.network = { + mode = "tap"; + tapName = "agent0"; + mac = "02:00:00:00:00:10"; + guestAddress = "192.168.77.2"; + prefixLength = 24; + gateway = "192.168.77.1"; + dns = [ "YOUR_REACHABLE_DNS_IP" ]; + hostAddress = "127.0.0.1"; # Web publication is still independently configurable. +}; +``` + +An administrator must first provision `agent0`, owned by the launching user, +with `multi_queue` when using multiple vCPUs, and arrange the host's address, +routing/NAT/DNS/firewall. For example, the interface creation portion is +`sudo ip tuntap add agent0 mode tap user "$USER" multi_queue`. That command alone +**does not** establish routing. See microvm.nix's linked routed-network docs for +persistent NixOS setup. Do not substitute a physical Wi-Fi/Ethernet interface for +`tapName`, or run QEMU as root. TAP joins a less isolated network; the host's +loopback publication default does not hide guest SSH from other routed peers. + +## What “latest” means + +- **Nix:** moving `nixos-unstable` (latest tested rolling channel), microvm.nix's + default branch, and `inputs.*.follows` to keep the project's package set coherent. + `nix flake update` refreshes the resolved snapshots; restart to use them. + `flake.lock` is retained because that is how flakes work, not a manually selected + old package version. The helper's own lock and each consuming project's lock + are independent. +- **DSH:** `npm exec --yes --package=@deepseek-ai/dsh@latest -- dsh ...` runs + **inside the VM**. Each launch resolves the current npm `latest` dist-tag, + using a persistent download cache. Network/registry availability is required; + failed installs are visible in `journalctl -u agent`. There is no fixed DSH + version, handwritten dependency hash, host `npm -g`, or claim of reproducibility + for this mutable part. `latest` can itself be a release candidate. A two-line + Node shim supplies `--expose-internals`, required by the current Cordis HMR + dependency but missing from the published CLI's shebang. +- **Plugins:** explicitly opt in, then update with DSH/pnpm. No unreviewed community + plugins are automatically installed or upgraded by this repository. +- **Workstation:** the root `/etc/nix` flake now also follows `nixos-unstable` and + Home Manager `master`; CLI tools still use Nixpkgs `master`. Its existing daily + updater refreshes those inputs and stages the tested result for next boot. + `stateVersion = "26.05"` remains a compatibility setting, **not a package pin**. + The existing separate Neovim-dotfile revision and manually packaged Element + binary are unchanged; their documented update boundaries still apply. + +## Plugins worth considering + +DSH is still a developer preview. Popularity is not a security audit or a promise +of compatibility with tomorrow's `latest`. These are actual DSH plugins, not +OpenCode plugins relabeled as DeepSeek plugins. GitHub stars checked 2026-09-06: + +| Plugin | Why consider it | Approx. repository stars | +| --- | --- | --- | +| [DSH better sidebar](https://github.com/omdsh-dev/DSH-better-sidebar) | File explorer/editor, Git and terminal alongside the conversation | 3.4k | +| [dsh-market](https://github.com/dsh-market/dsh-market) | Discover/manage plugins in the DSH settings UI | 3.3k | +| [dsh-agent-teams](https://github.com/NanmiCoder/dsh-agent-teams) | Multi-agent delegation; add only when you need it, as it can multiply API cost | 1.4k | +| [dsh-context](https://github.com/bowenliang123/dsh-context) | Inspect context/token use and manage context | 1.3k | + +Start with **dsh-context**, then optionally the sidebar. A marketplace and a +multi-agent orchestrator are not necessary to make the core harness useful. +Review their source first; native-addon build prerequisites vary. Example: + +```sh +nix run .#agent -- ssh +# Now INSIDE the VM, with your real DSH home mounted RW: +dsh plugin --profile web add dsh-context@latest +# Optional, separately reviewed: +dsh plugin --profile web add dshmarket@latest +systemctl restart agent +exit +nix run .#agent -- url # restart creates a fresh browser launch token +``` + +Use `dsh plugin --profile web update --latest` when you explicitly want to update +installed plugins. Review pnpm's build-script approval requests rather than +blanket-approving everything. The built-in MCP bridge can also connect to popular +projects such as [Context7](https://github.com/upstash/context7) and +[Playwright MCP](https://github.com/microsoft/playwright-mcp); those are separate +MCP services, not evidence that a particular DSH adapter is widely deployed. +Playwright needs its browsers/dependencies inside the guest. No browser sessions, +external MCP credentials or arbitrary host skill symlink targets are imported. + +## Sandbox boundary and limitations + +- Trust the project's flake and launcher before `nix run`: an arbitrary flake app + is host code and can choose not to launch this sandbox at all. +- Separate KVM guest kernel; agent is root only there. Rootless QEMU is additionally + confined by Bubblewrap: restricted filesystem, private process/IPC/UTS/user + namespaces, dropped capabilities, no inherited host environment. +- No host home mount, Nix daemon/socket, SSH-agent forwarding, Docker socket, + desktop session sockets, or full host Nix-store share **inside the guest**. + QEMU itself needs read-only access to `/nix/store` to run its host binaries. +- Writable **cwd + DSH home + shared skills are intentional holes in the boundary**. + A bad agent/plugin can delete those files, steal tokens, corrupt Git metadata, + and plant malicious skills/plugins for future host runs. Back them up and use + scoped/revocable API keys. Never execute the shared plugin state on the host + without trusting changes made in the VM. +- Symlinks outside the shares do not grant those extra host directories. This + also means external Git-worktree metadata, symlinked skills, local path flake + inputs and host-built `node_modules` may not work. Use self-contained checkouts + and Linux-compatible dependencies, not wider home mounts to make errors vanish. +- Outgoing networking is **not filtered**: API access, exfiltration, host/LAN + services and cloud metadata can be reachable. This is filesystem/process + isolation, not an egress-security appliance. Use a separate filtered network + or machine for hostile code and don't give it real shared credentials. +- 9p favors a small rootless setup over maximum filesystem throughput. Remote + host edits may require a DSH refresh/restart for watchers to notice them. +- Latest code, kernels, QEMU and mounts can contain vulnerabilities. This is + defense in depth, not a claim of an audited or escape-proof sandbox. + +## Checks and sources + +```sh +nix flake check path:/etc/nix/agent-vm --no-build +nix build path:/etc/nix/agent-vm#checks.x86_64-linux.config \ + path:/etc/nix/agent-vm#checks.x86_64-linux.shell --no-link +# Actual offline microVM boot/mount/SSH test, with a fake harness (no API calls): +nix build path:/etc/nix/agent-vm#checks.x86_64-linux.boot --no-link +``` + +The boot test requires KVM and nested user namespaces in the Nix build sandbox. +A real `dsh@latest` Web startup additionally requires network access and is not +silently replaced by the fake harness in normal usage. + +Validated here: template/host/helper flake evaluation, ShellCheck, the real +microVM offline boot/mount/ownership/isolation test, and a separate live official +DSH Web launch with disposable config. The live check returned **401** without +a cookie, **200** after the token exchange, and **403** for an untrusted Host; +DSH stayed running without restarts. No real API credentials, model requests, +community plugins, TAP network provisioning, or host activation were involved. + +Research used the **new official wiki**, plus upstream sources: + +- [NixOS wiki: Flakes](https://wiki.nixos.org/wiki/Flakes) — lockfiles, pure inputs, + dev shells, apps, and Git-tracked source. +- [NixOS wiki: Virtualization](https://wiki.nixos.org/wiki/Virtualization) — points + to [microvm.nix](https://microvm-nix.github.io/microvm.nix/declaring.html). +- [microvm interfaces](https://microvm-nix.github.io/microvm.nix/interfaces.html), + [shares](https://microvm-nix.github.io/microvm.nix/shares.html), and + [routed networking](https://microvm-nix.github.io/microvm.nix/routed-network.html). +- [DSH CLI reference](https://github.com/deepseek-ai/deepseek-harness/blob/master/apps/cli/reference/README.md), + [Web guide](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/guide/index.md), + [skill paths](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/skill/skill-filesystem/README.md), + [browser auth](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/connection/README.md), + [safety notice](https://github.com/deepseek-ai/deepseek-harness/blob/master/SAFETY.md). +- [Awesome DSH plugins](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) + plus the individual repositories above; no third-party plugin was installed. diff --git a/README.md b/README.md index 9a2aa56..4902884 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Flat, explicit NixOS modules with locked inputs. Required setup belongs here—n | `desktop-test.nix`, `desktop-test.py`, `audit-desktop.sh` | Disposable graphical/PAM/audio/scaling audit | | `workstation.nix`, `nvidia.nix` | Shared local hardware/SDDM integration for laptop and VM; separate opt-in NVIDIA support | | [DESKTOP.md](DESKTOP.md) | Live audit, wallpaper provenance, JaKooLit comparison and explicit feature-completion plan | +| `agent-vm/`, `templates/agent/`, [AGENT-VM.md](AGENT-VM.md) | Reusable rootless DSH microVM, shared project toolchain, live RW cwd/config/credentials/skills and access/networking guide | ## Account and session @@ -90,6 +91,14 @@ See [DESKTOP.md](DESKTOP.md) for the screenshot-led audit, functional coverage, These are system-owned executables from Nix, not unmanaged `npm -g`, `pip install --user` or `cargo install` bootstraps. Project dependencies may still be downloaded by their ordinary package managers. `nix develop` / `.envrc` remain appropriate for project-specific versions; this is not a promise that every language project uses the same global toolchain. +### Per-project DeepSeek Harness + +[AGENT-VM.md](AGENT-VM.md) documents the project template and `nix run .#agent`. +The official `dsh@latest` runs as root in a rootless microVM, with the current +project and your standard DSH home/shared skills mounted **read-write**. Its +host Web listener defaults to localhost; RAM, vCPUs, IPs and optional TAP +networking are configured through Nix modules. No host service is activated. + ### Local Tor client On both hosts, `network.nix` installs Tor from the **system pin** and enables `tor.service` at boot. It runs as the dedicated `tor` user with the NixOS module's sandbox and private persistent state in `/var/lib/tor`. Systemd restarts an exited daemon after five seconds without a retry limit; an explicit `systemctl stop tor` still stops it normally. A running process does not guarantee network connectivity—check for `Bootstrapped 100%` in the journal. diff --git a/agent-vm/boot-test.sh b/agent-vm/boot-test.sh new file mode 100644 index 0000000..c8f29be --- /dev/null +++ b/agent-vm/boot-test.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Offline test of the real microvm.nix runner, mounts, SSH and host-side sandbox. +set -euo pipefail +launcher=$1 +tmp=$(mktemp -d) +export HOME=$tmp/home DSH_HOME=$tmp/dsh DSH_AGENTS_HOME=$tmp/agents XDG_STATE_HOME=$tmp/state +mkdir -p "$HOME" "$tmp/project" "$DSH_HOME/skills" "$DSH_AGENTS_HOME/skills" +chmod 700 "$DSH_HOME" +printf 'not shared\n' > "$HOME/host-only-secret" +ln -s "$HOME/host-only-secret" "$tmp/project/escape" +cd "$tmp/project" +"$launcher" run > "$tmp/launcher.log" 2>&1 & +pid=$! +cleanup() { + status=$? + if (( status )); then + grep -h . "$tmp/launcher.log" "$XDG_STATE_HOME"/agent-vm/*/console.log | tail -80 || true + fi + "$launcher" stop >/dev/null 2>&1 || true + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + # Never delete real project/config data; everything here is a test fixture. + rm -rf "$tmp" + return "$status" +} +trap cleanup EXIT +ready=false +for ((i=0; i<120; i++)); do + if "$launcher" ssh true 2>/dev/null; then ready=true; break; fi + if ! kill -0 "$pid" 2>/dev/null; then break; fi + sleep 2 +done +if ! $ready; then + grep -h . "$tmp/launcher.log" "$XDG_STATE_HOME"/agent-vm/*/console.log || true + exit 1 +fi +[[ $("$launcher" ssh 'id -u') == 0 ]] +[[ $("$launcher" ssh pwd) == "$tmp/project" ]] +[[ $("$launcher" ssh nproc) == 4 ]] +[[ $("$launcher" ssh 'printenv AGENT_PROJECT_TEST') == shared ]] +[[ $("$launcher" ssh hello) == 'Hello, world!' ]] +"$launcher" ssh 'test ! -e /workspace/escape; test ! -e /run/host; test -d /nix/.rw-store' +"$launcher" ssh 'printf edited > /workspace/changed; printf config > /root/.dsh/config-test; printf creds > /root/.dsh/credentials-test; printf skill > /root/.dsh/skills/test.md; printf shared > /root/.agents/skills/test.md' +[[ $(< changed) == edited && $(< "$DSH_HOME/config-test") == config ]] +[[ $(< "$DSH_HOME/credentials-test") == creds && $(< "$DSH_HOME/skills/test.md") == skill ]] +[[ $(< "$DSH_AGENTS_HOME/skills/test.md") == shared ]] +[[ $(stat -c %u changed) == "$(id -u)" ]] +"$launcher" ssh 'command -v rg python3 git; findmnt /workspace; findmnt /root/.dsh' +# A second start must fail without disrupting the existing VM. +if "$launcher" run >/dev/null 2>&1; then echo 'Duplicate launch succeeded' >&2; exit 1; fi +"$launcher" stop +wait "$pid" +trap - EXIT +rm -rf "$tmp" +echo 'PASS: microVM boot, root SSH, shared toolchain, RW cwd/config/creds/skills, host ownership, symlink isolation, duplicate lock, shutdown' diff --git a/agent-vm/flake.lock b/agent-vm/flake.lock new file mode 100644 index 0000000..fa9c268 --- /dev/null +++ b/agent-vm/flake.lock @@ -0,0 +1,65 @@ +{ + "nodes": { + "microvm": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "spectrum": "spectrum" + }, + "locked": { + "lastModified": 1788636433, + "narHash": "sha256-iCLUJO5V2ZlEAlYxlkCZ5YLkyQrpkyXOMUTkPXjsdPk=", + "owner": "microvm-nix", + "repo": "microvm.nix", + "rev": "804cbac7a462aa0fa8bb60c3d2fc4ead0a62060f", + "type": "github" + }, + "original": { + "owner": "microvm-nix", + "repo": "microvm.nix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1788614874, + "narHash": "sha256-7QYjT2vHLuX9Z1pdxHXDKCbh1CR3D/2rywB9Tx0MPRg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "c043004d1c6985732bcc1cbc5a9c9aecbbb4e0f0", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "microvm": "microvm", + "nixpkgs": "nixpkgs" + } + }, + "spectrum": { + "flake": false, + "locked": { + "lastModified": 1785761586, + "narHash": "sha256-MWOMVqJJERwjQGgRIv8d6rlEBqbLM3VZWxHkNKIIZNw=", + "ref": "refs/heads/main", + "rev": "a7762d6f54b40560dd5255ce902e6e6a5d980fe9", + "revCount": 1416, + "type": "git", + "url": "https://spectrum-os.org/git/spectrum" + }, + "original": { + "type": "git", + "url": "https://spectrum-os.org/git/spectrum" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/agent-vm/flake.nix b/agent-vm/flake.nix new file mode 100644 index 0000000..c065c70 --- /dev/null +++ b/agent-vm/flake.nix @@ -0,0 +1,39 @@ +{ + description = "Project-composable, rootless microVMs for a DeepSeek coding agent"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + microvm = { + url = "github:microvm-nix/microvm.nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = + inputs@{ + self, + nixpkgs, + microvm, + ... + }: + let + system = "x86_64-linux"; + pkgs = nixpkgs.legacyPackages.${system}; + example = self.lib.mkAgentVM { + inherit system; + project = { + packages = [ pkgs.hello ]; + env.AGENT_PROJECT_TEST = "shared"; + }; + }; + in + { + lib.mkAgentVM = import ./lib.nix { inherit nixpkgs microvm; }; + nixosModules.agent = ./module.nix; + nixosConfigurations.agent = example.nixos; + packages.${system}.default = example.package; + apps.${system}.default = example.app; + formatter.${system} = pkgs.nixfmt; + checks.${system} = import ./tests.nix { inherit inputs pkgs example; }; + }; +} diff --git a/agent-vm/launch.sh b/agent-vm/launch.sh new file mode 100644 index 0000000..d16f16e --- /dev/null +++ b/agent-vm/launch.sh @@ -0,0 +1,121 @@ +# Included by writeShellApplication: bash and PATH are supplied by Nix. +set -euo pipefail +if [[ ${1:-} == --help ]]; then + echo 'Usage: nix run .#agent -- [run | ssh [command ...] | url | stop]' + echo 'Workspace = cwd. RW config/credentials/skills = DSH_HOME (default ~/.dsh)' + echo 'Also shares DSH_AGENTS_HOME/skills (default ~/.agents/skills). RAM/CPU/network: flake.' + exit 0 +fi +[[ $EUID != 0 ]] || { echo 'Run as your normal host user, not sudo/root.' >&2; exit 1; } +umask 077 +project=$(pwd -P) +dsh=$(realpath -m "${DSH_HOME:-$HOME/.dsh}") +skills=$(realpath -m "${DSH_AGENTS_HOME:-$HOME/.agents}/skills") +state=$(realpath -m "${XDG_STATE_HOME:-$HOME/.local/state}/agent-vm/$(printf %s "$project" | sha256sum | cut -c1-16)") +for path in "$project" "$dsh" "$skills"; do + case "$path" in /|/home|/etc|/nix|/nix/*|/proc|/proc/*|/sys|/sys/*|/dev|/dev/*|/run|/run/*|"$HOME"|*$'\n'*) echo "Refusing broad/system share: $path" >&2; exit 1;; esac + [[ $state != "$path" && $state != "$path/"* ]] || { echo 'State must be outside shared directories.' >&2; exit 1; } +done +# Prevent a broad workspace/config mount from accidentally including other mounts. +disjoint() { + [[ $1 != "$2" && $1 != "$2/"* && $2 != "$1/"* ]] || { echo 'Writable shares must not overlap.' >&2; exit 1; } +} +disjoint "$project" "$dsh"; disjoint "$project" "$skills"; disjoint "$dsh" "$skills" +ssh_cmd=(ssh -F /dev/null -i "$state/client-key" -p "$AGENT_SSH_PORT" + -o IdentitiesOnly=yes -o IdentityAgent=none -o ForwardAgent=no -o BatchMode=yes + -o StrictHostKeyChecking=yes -o HostKeyAlias=agent-vm -o ConnectTimeout=3 + -o "UserKnownHostsFile=$state/known_hosts" -o GlobalKnownHostsFile=/dev/null) +remote="root@$AGENT_SSH_HOST" +url() { + local found address=$AGENT_WEB_BIND + [[ $address != 0.0.0.0 ]] || address=127.0.0.1 + found=$("${ssh_cmd[@]}" "$remote" 'journalctl -u agent -b -o cat --no-pager' | + grep -oE 'http://127\.0\.0\.1:3080/\?token=[a-zA-Z0-9_%.-]+' | tail -1) || return 1 + [[ -n $found ]] || return 1 + printf '%s\n' "${found/http:\/\/127.0.0.1:3080/http:\/\/$address:$AGENT_WEB_PORT}" +} +# Expand cwd inside the guest, not on the host. +# shellcheck disable=SC2016 +case ${1:-run} in + ssh) shift; if (( $# )); then exec "${ssh_cmd[@]}" "$remote" 'cd -- "$(cat /run/agent-vm/workdir)" || exit; '"$*"; else exec "${ssh_cmd[@]}" -t "$remote" 'cd -- "$(cat /run/agent-vm/workdir)" || exit; exec bash -l'; fi;; + url) url || { echo "DSH not ready; inspect: nix run .#agent -- ssh 'journalctl -u agent -b'" >&2; exit 1; }; exit;; + stop) cd "$state"; exec "$AGENT_RUNNER/microvm-shutdown";; + run) [[ $# -le 1 ]] || { echo 'Unexpected run arguments; use --help.' >&2; exit 1; };; + *) echo 'Unknown command; use --help.' >&2; exit 1;; +esac +[[ -r /dev/kvm && -w /dev/kvm ]] || { echo 'Need read/write access to /dev/kvm.' >&2; exit 1; } +mkdir -p "$state" "$dsh" "$skills" +for dir in "$state" "$dsh"; do + [[ $(stat -c %u "$dir") == "$(id -u)" && $(stat -c %a "$dir") == 700 ]] || { + echo "Make this directory private and user-owned first: $dir (chmod 700)" >&2; exit 1; + } +done +exec 9>"$state/run.lock" +flock -n 9 || { echo 'This project VM is already running.' >&2; exit 1; } +for key in client-key ssh-host-key; do + [[ -f $state/$key ]] || ssh-keygen -q -t ed25519 -N '' -C agent-vm -f "$state/$key" +done +printf '%s\n' "$project" > "$state/workdir" +cp "$state/client-key.pub" "$state/ssh-authorized-key" +printf 'agent-vm %s\n' "$(cut -d' ' -f1,2 "$state/ssh-host-key.pub")" > "$state/known_hosts" +printf 'RW workspace: %s -> /workspace\nRW DSH home: %s\nRW shared skills: %s\nConsole log: %s/console.log\n' "$project" "$dsh" "$skills" "$state" +if [[ $AGENT_WEB_BIND != 127.0.0.1 ]]; then + echo 'WARNING: off-host Web access is plaintext HTTP. Use a VPN/TLS; never expose directly to the Internet.' >&2 +fi +vm_pid='' +tunnel_pid='' +cleanup() { + trap - EXIT INT TERM + if [[ -n $vm_pid ]] && kill -0 "$vm_pid" 2>/dev/null; then + (cd "$state"; timeout 30 "$AGENT_RUNNER/microvm-shutdown") >/dev/null 2>&1 || true + kill "$vm_pid" 2>/dev/null || true + wait "$vm_pid" 2>/dev/null || true + fi + if [[ -n $tunnel_pid ]]; then kill "$tunnel_pid" 2>/dev/null || true; wait "$tunnel_pid" 2>/dev/null || true; fi +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +# Host-side defense in depth around QEMU. No host home/session sockets or other +# processes; only the three explicit shares and private VM control state are RW. +# Network is intentionally inherited for API access (not an egress firewall). +devices=() +[[ $AGENT_NETWORK != tap ]] || devices=(--dev-bind /dev/net/tun /dev/net/tun) +bwrap "${devices[@]}" --die-with-parent --new-session --unshare-user --unshare-pid --unshare-ipc \ + --unshare-uts --unshare-cgroup-try --cap-drop ALL --clearenv \ + --setenv HOME /tmp --setenv PATH /no-host-path --setenv LANG C.UTF-8 \ + --ro-bind /nix/store /nix/store --proc /proc --dev /dev --dev-bind /dev/kvm /dev/kvm \ + --tmpfs /tmp --bind "$state" /state --bind "$project" /workspace \ + --bind "$dsh" /dsh-home --bind "$skills" /skills \ + --ro-bind-try /etc/resolv.conf /etc/resolv.conf --ro-bind-try /etc/hosts /etc/hosts \ + --chdir /state "$AGENT_RUNNER/microvm-run" >"$state/console.log" 2>&1 & +vm_pid=$! +ready=false +for ((i=0; i<90; i++)); do + kill -0 "$vm_pid" 2>/dev/null || { echo "VM exited; see $state/console.log" >&2; exit 1; } + if "${ssh_cmd[@]}" "$remote" true 2>/dev/null; then ready=true; break; fi + sleep 1 +done +$ready || { echo "SSH boot timeout; see $state/console.log" >&2; exit 1; } +# DSH deliberately refuses --host 0.0.0.0. Keep its own authenticated browser +# endpoint on guest loopback and publish an SSH forward on the chosen host IP. +"${ssh_cmd[@]}" -N -g -o ExitOnForwardFailure=yes -o ServerAliveInterval=10 \ + -o ServerAliveCountMax=3 -L "$AGENT_WEB_BIND:$AGENT_WEB_PORT:127.0.0.1:3080" \ + "$remote" >>"$state/console.log" 2>&1 & +tunnel_pid=$! +echo "Booted. DSH resolves npm @latest on startup; first launch may take a few minutes." +echo 'Use another terminal: nix run .#agent -- url (or: ssh / stop)' +printed=false +while kill -0 "$vm_pid" 2>/dev/null; do + # Normal guest poweroff can close SSH slightly before QEMU exits. + if ! kill -0 "$tunnel_pid" 2>/dev/null; then + timeout 30 tail --pid="$vm_pid" -f /dev/null || true + if kill -0 "$vm_pid" 2>/dev/null; then echo "Web tunnel exited; see $state/console.log" >&2; exit 1; fi + break + fi + if ! $printed; then + if login_url=$(url 2>/dev/null); then printf 'Private login URL: %s\n' "$login_url"; printed=true; fi + fi + sleep 2 +done +wait "$vm_pid" diff --git a/agent-vm/lib.nix b/agent-vm/lib.nix new file mode 100644 index 0000000..5202694 --- /dev/null +++ b/agent-vm/lib.nix @@ -0,0 +1,54 @@ +{ nixpkgs, microvm }: +{ + system, + project, + modules ? [ ], +}: +let + pkgs = nixpkgs.legacyPackages.${system}; + inherit (pkgs) lib; + nixos = nixpkgs.lib.nixosSystem { + inherit system; + modules = [ + microvm.nixosModules.microvm + ./module.nix + { + agentVM.packages = project.packages or [ ]; + environment.variables = project.env or { }; + } + ] + ++ modules; + }; + net = nixos.config.agentVM.network; + package = pkgs.writeShellApplication { + name = "agent-vm"; + runtimeInputs = with pkgs; [ + coreutils + util-linux + openssh + bubblewrap + gnugrep + gnused + ]; + runtimeEnv = { + AGENT_RUNNER = "${nixos.config.microvm.declaredRunner}/bin"; + AGENT_NETWORK = net.mode; + AGENT_WEB_BIND = net.hostAddress; + AGENT_WEB_PORT = toString net.webPort; + AGENT_SSH_HOST = if net.mode == "user" then "127.0.0.1" else net.guestAddress; + AGENT_SSH_PORT = toString (if net.mode == "user" then net.sshPort else 22); + }; + text = builtins.readFile ./launch.sh; + }; +in +assert lib.assertMsg ( + system == "x86_64-linux" +) "agent-vm currently supports x86_64-linux hosts/guests"; +{ + inherit nixos package; + app = { + type = "app"; + program = lib.getExe package; + meta.description = "Run DSH with this project's toolchain and live workspace"; + }; +} diff --git a/agent-vm/module.nix b/agent-vm/module.nix new file mode 100644 index 0000000..0281843 --- /dev/null +++ b/agent-vm/module.nix @@ -0,0 +1,376 @@ +{ + config, + lib, + pkgs, + ... +}: +let + inherit (lib) mkOption types; + cfg = config.agentVM; + net = cfg.network; + ipv4 = types.strMatching "[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+"; + dshNode = pkgs.writeShellScript "dsh-node" '' + # Cordis HMR requires this Node flag; npm's published dsh shebang omits it. + exec node --expose-internals "$(command -v dsh)" "$@" + ''; + dshLatest = pkgs.writeShellApplication { + name = "dsh"; + runtimeInputs = [ + pkgs.nodejs + pkgs.pnpm + ]; + text = '' + export npm_config_cache=/var/cache/dsh/npm + # Explicitly rolling upstream, not a pretend-reproducible Nix derivation. + exec npm exec --yes --package=@deepseek-ai/dsh@latest -- ${dshNode} "$@" + ''; + }; + guestLaunch = pkgs.writeShellScript "dsh-project" '' + cd -- "$(cat /run/agent-vm/workdir)" + exec "$@" + ''; + share = source: mountPoint: tag: { + inherit source mountPoint tag; + proto = "9p"; + securityModel = "none"; # QEMU writes as its unprivileged host uid, not guest root. + readOnly = false; + }; +in +{ + options.agentVM = { + packages = mkOption { + type = types.listOf types.package; + default = [ ]; + description = "The same project package list used by the development shell."; + }; + package = mkOption { + type = types.package; + default = dshLatest; + description = "Official DSH launcher; resolves the npm latest tag inside the guest at launch."; + }; + network = { + mode = mkOption { + type = types.enum [ + "user" + "tap" + ]; + default = "user"; + description = "Rootless QEMU NAT, or an administrator-prepared TAP interface."; + }; + hostAddress = mkOption { + type = ipv4; + default = "127.0.0.1"; + description = "Host IPv4 bind address for the SSH-forwarded Web UI."; + }; + sshPort = mkOption { + type = types.port; + default = 2222; + description = "Host SSH port in user mode; SSH is always bound to host loopback."; + }; + webPort = mkOption { + type = types.port; + default = 3080; + description = "Host Web UI port; the guest DSH listener stays on 127.0.0.1:3080."; + }; + trustedHosts = mkOption { + type = types.listOf (types.strMatching "[a-zA-Z0-9.:-]+"); + default = [ ]; + description = "Additional exact browser authorities for DSH's Host/Origin protection. Required for wildcard publication."; + }; + tapName = mkOption { + type = types.strMatching "[a-zA-Z0-9_-]{1,15}"; + default = "agent0"; + description = "Pre-created host TAP interface, not a physical NIC."; + }; + mac = mkOption { + type = types.strMatching "[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}"; + default = "02:00:00:00:00:01"; + description = "Unique locally administered guest MAC; change for each TAP guest."; + }; + guestAddress = mkOption { + type = types.nullOr ipv4; + default = null; + description = "Static guest IPv4 address in TAP mode."; + }; + prefixLength = mkOption { + type = types.ints.between 1 32; + default = 24; + description = "Guest IPv4 prefix length in TAP mode."; + }; + gateway = mkOption { + type = types.nullOr ipv4; + default = null; + description = "Guest default router in TAP mode; routing/NAT is configured separately."; + }; + dns = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "DNS servers in TAP mode. User mode uses QEMU DHCP/DNS."; + }; + }; + }; + + config = { + assertions = [ + { + assertion = + net.mode != "tap" || (net.guestAddress != null && net.gateway != null && net.dns != [ ]); + message = "agentVM TAP mode requires network.guestAddress, gateway and dns."; + } + { + assertion = net.webPort >= 1024 && net.sshPort >= 1024 && net.webPort != net.sshPort; + message = "Rootless Web/SSH listeners need distinct unprivileged ports (>=1024)."; + } + { + assertion = net.hostAddress != "0.0.0.0" || net.trustedHosts != [ ]; + message = "When publishing on 0.0.0.0, list the actual browser IP:port/hostname:port in network.trustedHosts."; + } + ]; + + networking.hostName = lib.mkDefault "project-agent"; + system.stateVersion = "26.05"; + microvm = { + hypervisor = "qemu"; + mem = lib.mkDefault 4096; + vcpu = lib.mkDefault 4; + socket = "control.sock"; + storeOnDisk = true; + # Ephemeral guest-only Nix writes; never share the host store or daemon. + writableStoreOverlay = "/nix/.rw-store"; + volumes = [ + { + image = "cache.img"; + mountPoint = "/var/cache/dsh"; + size = 4096; + } + ]; + # Paths are in the launcher's restricted mount namespace, not Nix paths. + # No credential/project contents enter the Nix store. + shares = [ + (share "/workspace" "/workspace" "project") + (share "/dsh-home" "/root/.dsh" "dsh-home") + (share "/skills" "/root/.agents/skills" "agent-skills") + ]; + interfaces = [ + { + type = net.mode; + id = if net.mode == "user" then "agentnet" else net.tapName; + inherit (net) mac; + } + ]; + forwardPorts = lib.optionals (net.mode == "user") [ + { + from = "host"; + host.address = "127.0.0.1"; + host.port = net.sshPort; + guest.port = 22; + } + ]; + # Firmware credentials carry only dedicated VM SSH keys, not DSH secrets. + # Relative runtime filenames avoid embedding user paths in derivations. + qemu.extraArgs = + lib.concatMap + (name: [ + "-fw_cfg" + "name=opt/io.systemd.credentials/${name},file=${name}" + ]) + [ + "ssh-authorized-key" + "ssh-host-key" + "workdir" + ]; + }; + fileSystems."/workspace".options = [ + "nodev" + "nosuid" + "cache=none" + ]; + fileSystems."/root/.dsh".options = [ + "nodev" + "nosuid" + "cache=none" + ]; + fileSystems."/root/.agents/skills".options = [ + "nodev" + "nosuid" + "cache=none" + ]; + + networking.useDHCP = false; + systemd.network.enable = true; + systemd.network.networks."20-agent" = { + matchConfig.MACAddress = net.mac; + networkConfig = + if net.mode == "user" then + { DHCP = "ipv4"; } + else + { + Address = [ "${net.guestAddress}/${toString net.prefixLength}" ]; + Gateway = net.gateway; + DNS = net.dns; + }; + }; + networking.firewall.allowedTCPPorts = [ 22 ]; # Web stays on guest loopback. + nix.settings.experimental-features = [ + "nix-command" + "flakes" + ]; + nix.settings.auto-optimise-store = false; + nix.channel.enable = false; + + users.users.root.hashedPassword = "!"; + services.openssh = { + enable = true; + hostKeys = [ + { + path = "/run/agent-vm/ssh-host-key"; + type = "ed25519"; + } + ]; + authorizedKeysFiles = lib.mkForce [ "/run/agent-vm/ssh-authorized-key" ]; + settings = { + PermitRootLogin = "prohibit-password"; + PasswordAuthentication = false; + KbdInteractiveAuthentication = false; + AllowAgentForwarding = false; + X11Forwarding = false; + AllowTcpForwarding = "local"; + }; + }; + systemd.services.agent-vm-credentials = { + before = [ + "sshd.service" + "sshd-keygen.service" + "agent.service" + ]; + requiredBy = [ + "sshd.service" + "sshd-keygen.service" + "agent.service" + ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + RuntimeDirectory = "agent-vm"; + RuntimeDirectoryMode = "0700"; + ImportCredential = [ + "ssh-authorized-key" + "ssh-host-key" + "workdir" + ]; + }; + script = '' + for name in ssh-authorized-key ssh-host-key workdir; do + install -m 600 "$CREDENTIALS_DIRECTORY/$name" "/run/agent-vm/$name" + done + ''; + }; + environment.variables = { + DSH_HOME = "/root/.dsh"; + DSH_AGENTS_HOME = "/root/.agents"; + DSH_TELEMETRY_DISABLED = "1"; + }; + programs.git.config.safe.directory = "/workspace"; # 9p files retain host ownership. + programs.nix-ld.enable = true; # Upstream npm native executables, guest only. + environment.systemPackages = [ + cfg.package + ] + ++ cfg.packages + ++ (with pkgs; [ + bashInteractive + coreutils + findutils + gnugrep + gnused + gawk + diffutils + git + git-lfs + openssh + ripgrep + fd + jq + yq-go + tree + file + less + python3 + nodejs + pnpm + curl + wget + cacert + unzip + zip + gnutar + gzip + xz + zstd + procps + util-linux + which + patch + gnumake + pkg-config + shellcheck + bubblewrap + ]); + systemd.services.agent = { + description = "Official DeepSeek Harness (root inside the guest)"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + unitConfig.RequiresMountsFor = [ + "/workspace" + "/root/.dsh" + "/root/.agents/skills" + "/var/cache/dsh" + ]; + path = [ "/run/current-system/sw" ]; + environment = config.environment.variables // { + HOME = "/root"; + }; + # Preserve the real cwd path in DSH's workspace/session identity. Mapping + # every project to /workspace alone would conflate their shared sessions. + preStart = '' + workdir=$(cat /run/agent-vm/workdir) + mkdir -p -- "$workdir" + mountpoint -q -- "$workdir" || mount --bind /workspace "$workdir" + git config --global --replace-all safe.directory "$workdir" + ''; + serviceConfig = { + User = "root"; + WorkingDirectory = "/workspace"; + ExecStart = lib.escapeShellArgs ( + [ + "${guestLaunch}" + "${cfg.package}/bin/dsh" + "web" + "--no-open" + "--host" + "127.0.0.1" + "--port" + "3080" + ] + ++ + lib.concatMap + (host: [ + "--trusted-host" + host + ]) + ( + net.trustedHosts + ++ lib.optional ( + !builtins.elem net.hostAddress [ + "127.0.0.1" + "0.0.0.0" + ] + ) "${net.hostAddress}:${toString net.webPort}" + ) + ); + Restart = "on-failure"; + RestartSec = 3; + UMask = "0077"; + }; + }; + }; +} diff --git a/agent-vm/tests.nix b/agent-vm/tests.nix new file mode 100644 index 0000000..5e2886e --- /dev/null +++ b/agent-vm/tests.nix @@ -0,0 +1,92 @@ +{ + inputs, + pkgs, + example, +}: +let + c = example.nixos.config; + testVM = inputs.self.lib.mkAgentVM { + system = pkgs.stdenv.hostPlatform.system; + project = { + packages = [ pkgs.hello ]; + env.AGENT_PROJECT_TEST = "shared"; + }; + modules = [ + { + # Offline infrastructure test. A real DSH startup is tested separately; + # @latest needs the network and is intentionally outside Nix reproducibility. + agentVM.package = pkgs.writeShellScriptBin "dsh" '' + echo 'http://127.0.0.1:3080/?token=offline-test' + exec ${pkgs.coreutils}/bin/sleep infinity + ''; + } + ]; + }; + tap = inputs.self.lib.mkAgentVM { + system = pkgs.stdenv.hostPlatform.system; + project.packages = [ ]; + modules = [ + { + microvm.mem = 8192; + microvm.vcpu = 6; + agentVM.network = { + mode = "tap"; + tapName = "agent-test"; + guestAddress = "192.168.77.2"; + gateway = "192.168.77.1"; + dns = [ "192.168.77.1" ]; + }; + } + ]; + }; +in +{ + config = + assert c.microvm.mem == 4096; + assert c.microvm.vcpu == 4; + assert builtins.length c.microvm.shares == 3; + assert builtins.all (s: !s.readOnly && s.securityModel == "none") c.microvm.shares; + assert c.microvm.storeOnDisk; + assert c.systemd.services.agent.serviceConfig.User == "root"; + assert c.systemd.services.agent.serviceConfig.WorkingDirectory == "/workspace"; + assert c.services.openssh.settings.PasswordAuthentication == false; + assert c.services.openssh.settings.AllowAgentForwarding == false; + assert builtins.length c.microvm.forwardPorts == 1; + assert (builtins.head c.microvm.forwardPorts).host.address == "127.0.0.1"; + assert builtins.elem pkgs.hello c.environment.systemPackages; + assert c.environment.variables.AGENT_PROJECT_TEST == "shared"; + assert tap.nixos.config.microvm.mem == 8192; + assert tap.nixos.config.microvm.vcpu == 6; + assert tap.nixos.config.microvm.forwardPorts == [ ]; + assert (builtins.head tap.nixos.config.microvm.interfaces).id == "agent-test"; + pkgs.runCommand "agent-vm-config-check" { } ''touch "$out"''; + + shell = + pkgs.runCommand "agent-vm-shell-check" + { + nativeBuildInputs = [ + pkgs.shellcheck + pkgs.bash + ]; + } + '' + shellcheck -s bash ${./launch.sh} ${./boot-test.sh} + bash -n ${./launch.sh} + touch "$out" + ''; + + boot = + pkgs.runCommand "agent-vm-boot-check" + { + requiredSystemFeatures = [ "kvm" ]; + nativeBuildInputs = [ + pkgs.bash + pkgs.coreutils + pkgs.gnugrep + ]; + } + '' + bash ${./boot-test.sh} ${testVM.package}/bin/agent-vm + touch "$out" + ''; +} diff --git a/flake.nix b/flake.nix index 53942da..3253fb8 100644 --- a/flake.nix +++ b/flake.nix @@ -39,6 +39,11 @@ nixos = mkHost ./physical.nix; # This laptop's boot/storage/hardware }; + templates.agent = { + path = ./templates/agent; + description = "Project toolchain + live-workspace DeepSeek Harness microVM"; + }; + formatter.x86_64-linux = nixpkgs.legacyPackages.x86_64-linux.nixfmt; packages.x86_64-linux.element-nightly = import ./element-nightly.nix { pkgs = nixpkgs.legacyPackages.x86_64-linux; diff --git a/templates/agent/.gitignore b/templates/agent/.gitignore new file mode 100644 index 0000000..ea09542 --- /dev/null +++ b/templates/agent/.gitignore @@ -0,0 +1,6 @@ +result +result-* +.env +.env.* +*.qcow2 +*.img diff --git a/templates/agent/README.md b/templates/agent/README.md new file mode 100644 index 0000000..3a56566 --- /dev/null +++ b/templates/agent/README.md @@ -0,0 +1,27 @@ +# Project + DeepSeek Harness microVM + +`project.nix` is the shared toolchain for `nix develop` and the guest. +`flake.nix` sets RAM, vCPUs and networking. Run from the project directory: + +```sh +nix run .#agent # boots with cwd mounted read-write at its original path +# Another terminal in the same directory: +nix run .#agent -- url # private browser login URL +nix run .#agent -- ssh # root shell, starting in the same project cwd +nix run .#agent -- stop +``` + +`$DSH_HOME` (default `~/.dsh`) and `${DSH_AGENTS_HOME:-~/.agents}/skills` are +also mounted **read-write**. No other home directories or host sockets are shared. +The first run creates missing DSH/skills directories. Existing DSH home must be +private (`chmod 700 ~/.dsh`). Credentials, settings, profiles and skills are live +shared files, not copied into the Nix store. Select the project's original +absolute path in the DSH UI; `/workspace` is also an alias. + +DSH resolves `@deepseek-ai/dsh@latest` inside the VM on startup. Nix packages +follow the rolling Nixpkgs input: `nix flake update`, then restart the VM. + +See `/etc/nix/AGENT-VM.md` for the full guide and security boundaries. The reusable +input lives at `/etc/nix/agent-vm`; replace the local input with your Git remote +when sharing this project. Keep backups: the agent can modify/delete the mounted +project and its shared DSH configuration/credentials/skills. diff --git a/templates/agent/flake.nix b/templates/agent/flake.nix new file mode 100644 index 0000000..8bb37d9 --- /dev/null +++ b/templates/agent/flake.nix @@ -0,0 +1,44 @@ +{ + description = "Project toolchain + DeepSeek Harness microVM"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + # Local bootstrap. For teammates/CI use your Git remote's moving branch: + # git+https://YOUR-REMOTE/nixconfig.git?dir=agent-vm&ref=main + agent-vm.url = "path:/etc/nix/agent-vm"; + agent-vm.inputs.nixpkgs.follows = "nixpkgs"; + }; + + outputs = + { nixpkgs, agent-vm, ... }: + let + system = "x86_64-linux"; + pkgs = nixpkgs.legacyPackages.${system}; + project = import ./project.nix { inherit pkgs; }; + agent = agent-vm.lib.mkAgentVM { + inherit system project; + modules = [ + { + microvm.mem = 4096; # MiB + microvm.vcpu = 4; + agentVM.network = { + hostAddress = "127.0.0.1"; # Or a host LAN/VPN IPv4 address, or 0.0.0.0. + sshPort = 2222; # Always host localhost in user-network mode. + webPort = 3080; + # For 0.0.0.0, list the actual browser authorities, not a wildcard: + # trustedHosts = [ "192.168.1.20:3080" ]; + }; + } + ]; + }; + in + { + devShells.${system}.default = pkgs.mkShell { + inherit (project) packages env; + }; + packages.${system}.agent = agent.package; + apps.${system}.agent = agent.app; + nixosConfigurations.agent = agent.nixos; + formatter.${system} = pkgs.nixfmt; + }; +} diff --git a/templates/agent/project.nix b/templates/agent/project.nix new file mode 100644 index 0000000..93d253b --- /dev/null +++ b/templates/agent/project.nix @@ -0,0 +1,18 @@ +{ pkgs }: +{ + # Single source of truth: both pkgs.mkShell and the VM consume this attrset. + # Replace/add your project's pinned toolchain here, not in two separate lists. + packages = with pkgs; [ + python3 + uv + ruff + nodejs + pnpm + git + ripgrep + ]; + # Shared non-secret variables. Do not put API tokens in a Nix expression. + env = { + UV_PYTHON_DOWNLOADS = "never"; + }; +}