23 KiB
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):
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:
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:
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:
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
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:
# 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.
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"andtrustedHosts = [ "192.168.1.20" "laptop.example" ];. Port-less entries accept that exact host on any port; an explicithost:portaccepts only that port. Use a real address, not0.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:
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, andinputs.*.followsto keep the project's package set coherent.nix flake updaterefreshes the resolved snapshots; restart to use them.flake.lockis 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 npmlatestdist-tag, using a persistent download cache. Network/registry availability is required; failed installs are visible injournalctl -u agent. There is no fixed DSH version, handwritten dependency hash, hostnpm -g, or claim of reproducibility for this mutable part.latestcan 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@latestinto thewebprofile once. Normal restarts keep the installed version; updates are explicit and targeted todsh-contextbelow. 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/nixflake now also followsnixos-unstableand Home Managermaster; CLI tools still use Nixpkgsmaster. 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 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:
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/storeto 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_modulesmay 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
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 — lockfiles, pure inputs, dev shells, apps, and Git-tracked source.
- NixOS wiki: Virtualization — points to microvm.nix.
- microvm interfaces, shares, and routed networking.
- DSH CLI reference, Web guide, skill paths, browser auth, safety notice.
- Official Playwright CLI, its session-management reference, and the CLI/core implementation shipped in the locked Nixpkgs Playwright package.
- dsh-context — upstream install, targeted update and context UI documentation.