# 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 VM is **headless by default**: use DSH's Web UI from your host browser; no VM desktop or display forwarding is started. Browser automation uses headless Firefox inside the VM, as described below. 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. Before starting the Web UI, the guest installs **only `dsh-context`** into the shared `web` profile if missing (details below). DSH/pnpm manages that profile's manifest, lockfile and dependencies; existing settings and other profiles stay intact. The one bundled `playwright-firefox` skill is seeded into the actual shared skills directory only if its `SKILL.md` is absent. It is a normal writable file (0600), not a store symlink; existing files and user edits are never overwritten. Root inside the guest is **not host root**. Bubblewrap maps the launching user's UID/GID to namespace `0:0`, so 9p ownership matches guest root and pnpm's atomic saves can preserve it. All capabilities remain dropped. QEMU/9p still 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/pnpm-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, plus the official **Playwright CLI and patched Firefox**. 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. ## Firefox automation and subagent sessions DSH discovers the bundled [playwright-firefox skill](agent-vm/skills/playwright-firefox/SKILL.md) from `/root/.agents/skills/playwright-firefox/SKILL.md`, which is the live host `${DSH_AGENTS_HOME:-~/.agents}/skills/playwright-firefox/SKILL.md`. No changes to `settings.yaml`, credentials, plugins or external MCP configuration are needed. Ask DSH/subagents to use **playwright-firefox** for browser tasks. The `playwright-cli` executable uses the official CLI shipped in Playwright core (`playwright-core cli`), with its matching Nixpkgs Firefox and rendering fonts. It defaults to **Firefox, headless, nonpersistent profiles**. This avoids an independently updated npm CLI demanding a different browser revision, or trying to automate ordinary Firefox without Playwright's Juggler support. CLI, patched browser and dependencies advance together with the consuming flake's Nixpkgs input; no handwritten Playwright version/hash or runtime browser download. Each subagent must use its own unique name and artifact directory. For example, inside the guest, from the same project cwd throughout: ```sh # Parent-assigned examples: give each task a different suffix. PLAYWRIGHT_MCP_OUTPUT_DIR="$PWD/.playwright-cli/login-a1b2c3d4" \ playwright-cli -s=login-a1b2c3d4 open http://127.0.0.1:3000 --browser=firefox PLAYWRIGHT_MCP_OUTPUT_DIR="$PWD/.playwright-cli/search-e5f6a7b8" \ playwright-cli -s=search-e5f6a7b8 open http://127.0.0.1:3000 --browser=firefox playwright-cli -s=login-a1b2c3d4 snapshot playwright-cli -s=search-e5f6a7b8 eval 'document.title' playwright-cli list playwright-cli -s=login-a1b2c3d4 close # leaves the other subagent alone playwright-cli -s=search-e5f6a7b8 close ``` Cookies, DOM, tabs and storage are independent between named sessions. This is **not a security boundary** between root-running subagents. The skill requires explicit `-s=` on every browser command, unique per-task output paths, modest parallelism (normally two browsers with 4 GiB RAM), and scoped cleanup. It forbids `close-all`, `kill-all`, global process killing and accidental use of the default session. Profiles are ephemeral unless persistence is explicitly requested; screenshots/traces/auth-state exports can contain secrets. New templates ignore `.playwright-cli/`; add that ignore rule to existing projects too. The application server must run **inside the VM** for guest `127.0.0.1` URLs. Headed operation is opt-in (`open --headed`) and needs a separately configured guest display; the default VM provides none and never mounts your host desktop. Do not import your normal browser profile or install another browser/CLI with npm. After updating the helper input, stop/restart the project VM to get the new tools and initial skill. Existing skill edits deliberately survive restarts and updates; merge later changes from the repository's skill file yourself rather than silently replacing local instructions. This does not restart an already-running VM. ## 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. By default it tries **3080 through 3100, in ascending order**, and keeps the first port SSH successfully binds. There is no separate free-port probe that could race another process. The printed login URL and `nix run .#agent -- url` use the chosen port, recorded in the project's private runtime state. Each new launch starts at 3080 again; shutdown clears the selection. If the range is full, launch fails clearly and cleans up its VM/tunnel instead of leaving an inaccessible instance running. 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; # First candidate. webPortEnd = 3100; # Last candidate, inclusive. }; ``` Set both values to the same port for a fixed listener. For compatibility, setting only a nondefault `webPort` still means that single fixed port; give `webPortEnd` explicitly to select a different range. Keep the SSH port outside the Web range. - **One LAN/VPN interface:** set `hostAddress = "192.168.1.20"` (an IP actually assigned to this host). Its exact authorities across the configured port range are automatically trusted. - **All IPv4 interfaces:** set `hostAddress = "0.0.0.0"` and `trustedHosts = [ "192.168.1.20" "laptop.example" ];`. Port-less entries accept that exact host on any port; an explicit `host:port` accepts only that port. 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 still need distinct **SSH** ports. Web ports are selected automatically from the range; SSH port selection is unchanged. - 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 the selected Web port (or intended Web range) on the intended host interface. Do not open the SSH forward. Use a fixed port if a reverse proxy needs a stable upstream. **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. - **Plugin:** the guest installs `dsh-context@latest` into the `web` profile once. Normal restarts keep the installed version; updates are explicit and targeted to `dsh-context` below. This is mutable DSH/pnpm state, not a Nix-pinned package. No other third-party plugin is added or automatically upgraded. - **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. ## Included context plugin [**dsh-context**](https://github.com/bowenliang123/dsh-context) is the only third-party plugin added by this setup. It provides the **Context** tab and **`/context`** command for context composition, token use and history. The guest's `agent.service` runs `dsh plugin --profile web add dsh-context@latest` before its first Web startup; no manual install or host package install is needed. Update the helper input and stop/restart existing VMs to pick up this setup change. The installed plugin and bundle registration live in the writable `$DSH_HOME/profiles/web`, alongside the profile's pnpm lockfile. Already-installed versions are left alone. An incomplete install (missing package or bundle registration) is retried with the profile's existing dependency spec, if any. Removing the required plugin causes it to be added again at the next service start. Existing user-installed plugins are **not removed**, and no settings, credentials, profile patches or other profiles are replaced. Use a separate `DSH_HOME` for an independent profile; don't run concurrent writers on one profile. First installation needs registry access. Failure blocks Web startup rather than silently omitting the plugin; inspect `journalctl -u agent -b` inside the VM. The DSH wrapper puts pnpm's store on `/var/cache/dsh/pnpm`: its SQLite index needs the guest's local cache disk, not the shared 9p filesystem. Installed packages and profile files remain shared. No build-script approval policy is relaxed. To explicitly update **only** this plugin, inside the guest: ```sh nix run .#agent -- ssh systemctl stop agent dsh plugin --profile web update dsh-context@latest systemctl start agent exit nix run .#agent -- url # restart creates a fresh browser launch token ``` DSH and this plugin remain developer-preview code, not an audited combination or a compatibility guarantee for tomorrow's `latest`. The Firefox CLI + skill needs no additional plugin or external MCP server. ## 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 \ path:/etc/nix/agent-vm#checks.x86_64-linux.context \ path:/etc/nix/agent-vm#checks.x86_64-linux.playwright --no-link # Actual offline microVM boot/mount/SSH/browser test, with a fake harness (no API calls): nix build path:/etc/nix/agent-vm#checks.x86_64-linux.boot --no-link # Real port contention, range exhaustion and remembered-URL checks: nix build path:/etc/nix/agent-vm#checks.x86_64-linux.ports --no-link ``` The boot and port tests require KVM and nested user namespaces in the Nix build sandbox. The context check uses a strict offline DSH fixture to check context-only install, restart idempotence, preservation of existing profile data/version selections, incomplete-install repair, failure/retry and malformed-profile rejection. The boot check also verifies setup before Web startup and writable host-owned plugin state that survives a service restart without reinstalling. The Playwright check launches two real Firefox instances concurrently against a local HTTP fixture, checks headless/default browser selection, separate cookies, DOM and localStorage, screenshots, close-one/keep-one behavior and profile expiry. The boot check runs it inside the guest too, and checks writable skill seeding, host ownership and preservation of a user edit across a harness restart. No live websites, real credentials or model calls are used. 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. That core-only check used no real API credentials, model requests, TAP network provisioning or host activation. The Firefox two-session regression also passed natively and in the real headless microVM, including screenshot generation and writable, non-clobbering skill seeding. A separate live VM check with a disposable DSH home installed only `dsh-context`, verified its composed bundle and authenticated HTTP **200**, then confirmed a service restart left the manifest/lockfile unchanged without reinstalling. No real credentials or model calls were used; the test VM and profile were removed. 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). - [Official Playwright CLI](https://github.com/microsoft/playwright-cli), its [session-management reference](https://github.com/microsoft/playwright-cli/blob/main/skills/playwright-cli/references/session-management.md), and the CLI/core implementation shipped in the locked Nixpkgs Playwright package. - [dsh-context](https://github.com/bowenliang123/dsh-context) — upstream install, targeted update and context UI documentation.