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.
This commit is contained in:
+308
@@ -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.
|
||||||
@@ -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 |
|
| `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 |
|
| `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 |
|
| [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
|
## 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.
|
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
|
### 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.
|
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.
|
||||||
|
|||||||
@@ -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'
|
||||||
Generated
+65
@@ -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
|
||||||
|
}
|
||||||
@@ -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; };
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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";
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
'';
|
||||||
|
}
|
||||||
@@ -39,6 +39,11 @@
|
|||||||
nixos = mkHost ./physical.nix; # This laptop's boot/storage/hardware
|
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;
|
formatter.x86_64-linux = nixpkgs.legacyPackages.x86_64-linux.nixfmt;
|
||||||
packages.x86_64-linux.element-nightly = import ./element-nightly.nix {
|
packages.x86_64-linux.element-nightly = import ./element-nightly.nix {
|
||||||
pkgs = nixpkgs.legacyPackages.x86_64-linux;
|
pkgs = nixpkgs.legacyPackages.x86_64-linux;
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
result
|
||||||
|
result-*
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.qcow2
|
||||||
|
*.img
|
||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user