Compare commits

..
4 Commits
Author SHA1 Message Date
ChatGPT 98126c4cfa feat: add Tauon from latest package pin 2026-09-06 22:21:58 -05:00
OpenAI Coding Assistant 8b6f90dad9 fix: keep systemd-resolved enabled on both hosts 2026-09-06 16:31:09 -05:00
OpenAI Coding Assistant 40d7de6b7f docs: replace agent plugin proposals with context setup 2026-09-06 15:08:10 -05:00
OpenAI Coding Assistant 15878fd3b7 feat: include only dsh-context in agent VM setup
Install through the upstream CLI before Web startup, preserve existing profile state and skip completed installs. Keep pnpm's SQLite store on the guest cache disk and map the caller to namespace root for 9p atomic saves, without host-root privileges.

Cover context-only installation, retries, idempotence and preservation with offline checks. Validate real VM boot, ports, Firefox and a live DSH/context launch using a disposable profile.
2026-09-06 15:08:10 -05:00
14 changed files with 309 additions and 58 deletions
+55 -37
View File
@@ -68,13 +68,19 @@ Missing directories are created. Existing DSH home must be user-owned and privat
`chmod 700 ~/.dsh` (or your actual `DSH_HOME`); existing credentials must be mode `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 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. 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 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), 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. not a store symlink; existing files and user edits are never overwritten.
Root inside the guest is **not host root**. QEMU/9p writes as the launching host Root inside the guest is **not host root**. Bubblewrap maps the launching user's
user, so newly created project files belong to you. The whole DSH directory is UID/GID to namespace `0:0`, so 9p ownership matches guest root and pnpm's atomic
mounted instead of individual credential files so atomic rename-based saves work. 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 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 sessions with a single `/workspace` identity. Do not run host DSH and several
VMs concurrently against the same mutable profile: package-fallback links and VMs concurrently against the same mutable profile: package-fallback links and
@@ -82,7 +88,7 @@ profile changes can race. Separate `DSH_HOME` values provide independent profile
The runner retains a private per-cwd directory under The runner retains a private per-cwd directory under
`${XDG_STATE_HOME:-~/.local/state}/agent-vm/`: dedicated SSH keys, console log, `${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, 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 processes and other unshared state are otherwise ephemeral. This is **not** a
push/pull/snapshot workflow; edits immediately affect the mounted host files. push/pull/snapshot workflow; edits immediately affect the mounted host files.
@@ -268,8 +274,10 @@ loopback publication default does not hide guest SSH from other routed peers.
for this mutable part. `latest` can itself be a release candidate. A two-line 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 Node shim supplies `--expose-internals`, required by the current Cordis HMR
dependency but missing from the published CLI's shebang. dependency but missing from the published CLI's shebang.
- **Plugins:** explicitly opt in, then update with DSH/pnpm. No unreviewed community - **Plugin:** the guest installs `dsh-context@latest` into the `web` profile once.
plugins are automatically installed or upgraded by this repository. 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 - **Workstation:** the root `/etc/nix` flake now also follows `nixos-unstable` and
Home Manager `master`; CLI tools still use Nixpkgs `master`. Its existing daily Home Manager `master`; CLI tools still use Nixpkgs `master`. Its existing daily
updater refreshes those inputs and stages the tested result for next boot. updater refreshes those inputs and stages the tested result for next boot.
@@ -277,43 +285,43 @@ loopback publication default does not hide guest SSH from other routed peers.
The existing separate Neovim-dotfile revision and manually packaged Element The existing separate Neovim-dotfile revision and manually packaged Element
binary are unchanged; their documented update boundaries still apply. binary are unchanged; their documented update boundaries still apply.
## Plugins worth considering ## Included context plugin
DSH is still a developer preview. Popularity is not a security audit or a promise [**dsh-context**](https://github.com/bowenliang123/dsh-context) is the only
of compatibility with tomorrow's `latest`. These are actual DSH plugins, not third-party plugin added by this setup. It provides the **Context** tab and
OpenCode plugins relabeled as DeepSeek plugins. GitHub stars checked 2026-09-06: **`/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.
| Plugin | Why consider it | Approx. repository stars | The installed plugin and bundle registration live in the writable
| --- | --- | --- | `$DSH_HOME/profiles/web`, alongside the profile's pnpm lockfile. Already-installed
| [DSH better sidebar](https://github.com/omdsh-dev/DSH-better-sidebar) | File explorer/editor, Git and terminal alongside the conversation | 3.4k | versions are left alone. An incomplete install (missing package or bundle
| [dsh-market](https://github.com/dsh-market/dsh-market) | Discover/manage plugins in the DSH settings UI | 3.3k | registration) is retried with the profile's existing dependency spec, if any.
| [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 | Removing the required plugin causes it to be added again at the next service
| [dsh-context](https://github.com/bowenliang123/dsh-context) | Inspect context/token use and manage context | 1.3k | 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.
Start with **dsh-context**, then optionally the sidebar. A marketplace and a First installation needs registry access. Failure blocks Web startup rather than
multi-agent orchestrator are not necessary to make the core harness useful. silently omitting the plugin; inspect `journalctl -u agent -b` inside the VM.
Review their source first; native-addon build prerequisites vary. Example: 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 ```sh
nix run .#agent -- ssh nix run .#agent -- ssh
# Now INSIDE the VM, with your real DSH home mounted RW: systemctl stop agent
dsh plugin --profile web add dsh-context@latest dsh plugin --profile web update dsh-context@latest
# Optional, separately reviewed: systemctl start agent
dsh plugin --profile web add dshmarket@latest
systemctl restart agent
exit exit
nix run .#agent -- url # restart creates a fresh browser launch token nix run .#agent -- url # restart creates a fresh browser launch token
``` ```
Use `dsh plugin --profile web update --latest` when you explicitly want to update DSH and this plugin remain developer-preview code, not an audited combination or
installed plugins. Review pnpm's build-script approval requests rather than a compatibility guarantee for tomorrow's `latest`. The Firefox CLI + skill needs
blanket-approving everything. The built-in MCP bridge can also connect to popular no additional plugin or external MCP server.
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.
The bundled Firefox **CLI + skill** above does not need Playwright MCP; use the
separate server only if you explicitly need that interface. No host browser
sessions, external MCP credentials or arbitrary host skill symlink targets are imported.
## Sandbox boundary and limitations ## Sandbox boundary and limitations
@@ -349,6 +357,7 @@ sessions, external MCP credentials or arbitrary host skill symlink targets are i
nix flake check path:/etc/nix/agent-vm --no-build nix flake check path:/etc/nix/agent-vm --no-build
nix build path:/etc/nix/agent-vm#checks.x86_64-linux.config \ 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.shell \
path:/etc/nix/agent-vm#checks.x86_64-linux.context \
path:/etc/nix/agent-vm#checks.x86_64-linux.playwright --no-link 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): # 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 nix build path:/etc/nix/agent-vm#checks.x86_64-linux.boot --no-link
@@ -357,6 +366,11 @@ 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 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 The Playwright check launches two real Firefox instances concurrently against a
local HTTP fixture, checks headless/default browser selection, separate cookies, local HTTP fixture, checks headless/default browser selection, separate cookies,
DOM and localStorage, screenshots, close-one/keep-one behavior and profile expiry. DOM and localStorage, screenshots, close-one/keep-one behavior and profile expiry.
@@ -370,10 +384,14 @@ Validated here: template/host/helper flake evaluation, ShellCheck, the real
microVM offline boot/mount/ownership/isolation test, and a separate live official microVM offline boot/mount/ownership/isolation test, and a separate live official
DSH Web launch with disposable config. The live check returned **401** without 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; a cookie, **200** after the token exchange, and **403** for an untrusted Host;
DSH stayed running without restarts. No real API credentials, model requests, DSH stayed running without restarts. That core-only check used no real API
community plugins, TAP network provisioning, or host activation were involved. credentials, model requests, TAP network provisioning or host activation.
The Firefox two-session regression also passed natively and in the real headless The Firefox two-session regression also passed natively and in the real headless
microVM, including screenshot generation and writable, non-clobbering skill seeding. 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: Research used the **new official wiki**, plus upstream sources:
@@ -392,5 +410,5 @@ Research used the **new official wiki**, plus upstream sources:
- [Official Playwright CLI](https://github.com/microsoft/playwright-cli), its - [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), [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. and the CLI/core implementation shipped in the locked Nixpkgs Playwright package.
- [Awesome DSH plugins](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) - [dsh-context](https://github.com/bowenliang123/dsh-context) — upstream install,
plus the individual repositories above; no third-party plugin was installed. targeted update and context UI documentation.
+16 -2
View File
@@ -16,7 +16,7 @@ Flat, explicit NixOS modules with locked inputs. Required setup belongs here—n
| `tools.nix`, `network.nix` | Development toolkit, terminal/shell, rootless Podman, VPN/proxy clients, local Tor service and network diagnostics | | `tools.nix`, `network.nix` | Development toolkit, terminal/shell, rootless Podman, VPN/proxy clients, local Tor service and network diagnostics |
| `colors.nix`, `wallpaper.nix`, `wallpaper.svg` | Shared One Ring palette, hash-pinned wallpaper and original fallback artwork | | `colors.nix`, `wallpaper.nix`, `wallpaper.svg` | Shared One Ring palette, hash-pinned wallpaper and original fallback artwork |
| `desktop.nix`, `hyprland.lua`, `anyrun.css`, `swaync.css`, `desktop-help.py`, `desktop-actions.py` | Session/bar, launchers, described help, capture, clipboard, notifications, lock/idle and styling | | `desktop.nix`, `hyprland.lua`, `anyrun.css`, `swaync.css`, `desktop-help.py`, `desktop-actions.py` | Session/bar, launchers, described help, capture, clipboard, notifications, lock/idle and styling |
| `apps.nix`, `element-nightly.nix` | Firefox ESR, Tor Browser, KeePassXC, Thunderbird, Steam, pinned Element Nightly, Slack, file/media viewers and MIME defaults | | `apps.nix`, `element-nightly.nix` | Firefox ESR, Tor Browser, KeePassXC, Thunderbird, Steam, pinned Element Nightly, Slack, Tauon, file/media viewers and MIME defaults |
| `neovim.nix`, `neovim-test.lua` | Unmodified upstream editor deployment and opt-in native runtime audit | | `neovim.nix`, `neovim-test.lua` | Unmodified upstream editor deployment and opt-in native runtime audit |
| `updates.nix`, `update-system.sh`, `update-test.py` | Shared dev-owned checkouts, daily boot-staged updates and failure/concurrency regression tests | | `updates.nix`, `update-system.sh`, `update-test.py` | Shared dev-owned checkouts, daily boot-staged updates and failure/concurrency regression tests |
| `switch-system.sh`, `switch-test.py` | Same installed manual apply/preview command on both hosts, with host identity supplied by Nix | | `switch-system.sh`, `switch-test.py` | Same installed manual apply/preview command on both hosts, with host identity supplied by Nix |
@@ -124,9 +124,17 @@ project and your standard DSH home/shared skills mounted **read-write**. Its
host Web listener defaults to localhost on the first free port in **30803100**; host Web listener defaults to localhost on the first free port in **30803100**;
RAM, vCPUs, IPs and optional TAP networking are configured through Nix modules. The VM is headless by default and RAM, vCPUs, IPs and optional TAP networking are configured through Nix modules. The VM is headless by default and
includes **Playwright CLI + matching Firefox**, with a writable, once-seeded includes **Playwright CLI + matching Firefox**, with a writable, once-seeded
`playwright-firefox` skill for isolated named sessions across subagents. No host `playwright-firefox` skill for isolated named sessions across subagents.
**`dsh-context` is the only added third-party plugin**: installed into the shared
Web profile on first startup, retained on restarts and updated explicitly. No host
service is activated. service is activated.
### System DNS
`network.nix` enables **`systemd-resolved.service` on both hosts at boot**, with automatic restart after five seconds and no retry limit. NetworkManager supplies per-link DNS on the laptop; EC2 retains dhcpcd. NixOS connects `/etc/resolv.conf` to resolved's stub and provides the D-Bus service used by `resolvectl`. Upstream DNS still comes from DHCP/VPN configuration, not hard-coded public or private servers. An explicit `systemctl stop systemd-resolved` still stops it normally.
Use `systemctl status systemd-resolved` and `resolvectl status` to inspect it. `sudo resolvectl dns krishna-laptop 192.168.1.19` sets DNS on that existing interface at runtime; it does not persist across interface recreation or reboot. Put persistent VPN DNS and any routing domains in the VPN/NetworkManager profile.
### 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.
@@ -234,6 +242,12 @@ Manual refresh: `nix flake update nixpkgs home-manager nixpkgs-latest`, review `
## Editor and application data ## Editor and application data
**Tauon** is installed for `dev` from `nixpkgs-latest` (Nixpkgs master), with the
exact snapshot recorded in `flake.lock`. The current pin provides **12.0.0**,
matching the latest upstream stable release when added. It advances through the
existing daily input-update workflow. Launch `tauon` or choose **Tauon**
in the application launcher after applying; existing MIME defaults are unchanged.
Neovim imports [the existing neovim-dots source](https://git.cyber.ayyalasomayajula.net/marsultor/neovim-dots), pinned to `380eb86778a7c53a0f1c18e84f14037456155347`. Lua, AstroNvim, Lazy, Mason, plugins and keymaps are unchanged. No Nixvim, replacement loader or plugin-manager migration. Neovim imports [the existing neovim-dots source](https://git.cyber.ayyalasomayajula.net/marsultor/neovim-dots), pinned to `380eb86778a7c53a0f1c18e84f14037456155347`. Lua, AstroNvim, Lazy, Mason, plugins and keymaps are unchanged. No Nixvim, replacement loader or plugin-manager migration.
Home Manager links configuration files under `~/.config/nvim`. Lazy's writable lock lives under `~/.local/state/nvim/locks/<revision>.json`, seeded once per dotfile revision. An earlier manual checkout is preserved at `~/projects/neovim-dots-before-nix`; activation refuses to overwrite an existing backup. The host input is reproducible; Lazy/Mason's mutable runtime downloads are not claimed to be fully Nix-reproducible. Home Manager links configuration files under `~/.config/nvim`. Lazy's writable lock lives under `~/.local/state/nvim/locks/<revision>.json`, seeded once per dotfile revision. An earlier manual checkout is preserved at `~/projects/neovim-dots-before-nix`; activation refuses to overwrite an existing backup. The host input is reproducible; Lazy/Mason's mutable runtime downloads are not claimed to be fully Nix-reproducible.
+11 -1
View File
@@ -47,14 +47,24 @@ fi
[[ $(< "$DSH_AGENTS_HOME/skills/test.md") == shared ]] [[ $(< "$DSH_AGENTS_HOME/skills/test.md") == shared ]]
[[ $(stat -c %u changed) == "$(id -u)" ]] [[ $(stat -c %u changed) == "$(id -u)" ]]
"$launcher" ssh 'command -v rg python3 git playwright-cli; findmnt /workspace; findmnt /root/.dsh' "$launcher" ssh 'command -v rg python3 git playwright-cli; findmnt /workspace; findmnt /root/.dsh'
# pnpm's atomic saves preserve ownership; these chowns must work over 9p.
"$launcher" ssh 'chown 0:0 /root/.dsh/config-test; chown --reference=/root/.dsh/config-test /root/.dsh/credentials-test'
"$launcher" ssh 'systemctl start agent.service; test ! -e /tmp/.X11-unix/X0' "$launcher" ssh 'systemctl start agent.service; test ! -e /tmp/.X11-unix/X0'
skill="$DSH_AGENTS_HOME/skills/playwright-firefox/SKILL.md" skill="$DSH_AGENTS_HOME/skills/playwright-firefox/SKILL.md"
grep -q '^name: playwright-firefox$' "$skill" grep -q '^name: playwright-firefox$' "$skill"
[[ ! -L $skill && -w $skill && $(stat -c %a "$skill") == 600 ]] [[ ! -L $skill && -w $skill && $(stat -c %a "$skill") == 600 ]]
[[ $(stat -c %u "$skill") == "$(id -u)" ]] [[ $(stat -c %u "$skill") == "$(id -u)" ]]
printf '\nuser customization\n' >> "$skill" printf '\nuser customization\n' >> "$skill"
manifest="$DSH_HOME/profiles/web/package.json"
[[ $(< "$DSH_HOME/plugin-calls") == 'plugin --profile web add dsh-context@latest' ]]
[[ -f $DSH_HOME/profiles/web/node_modules/dsh-context/package.json ]]
[[ ! -L $manifest && -w $manifest && $(stat -c %a "$manifest") == 600 ]]
[[ $(stat -c %u "$manifest") == "$(id -u)" ]]
cp "$manifest" "$tmp/installed.json"
"$launcher" ssh 'systemctl restart agent.service' "$launcher" ssh 'systemctl restart agent.service'
grep -q '^user customization$' "$skill" grep -q '^user customization$' "$skill"
[[ $(wc -l < "$DSH_HOME/plugin-calls") == 1 ]]
cmp "$manifest" "$tmp/installed.json"
if [[ -n $browser_test ]]; then if [[ -n $browser_test ]]; then
cp "$browser_test" ./playwright-test.sh cp "$browser_test" ./playwright-test.sh
"$launcher" ssh 'bash ./playwright-test.sh' "$launcher" ssh 'bash ./playwright-test.sh'
@@ -65,4 +75,4 @@ if "$launcher" run >/dev/null 2>&1; then echo 'Duplicate launch succeeded' >&2;
wait "$pid" wait "$pid"
trap - EXIT trap - EXIT
rm -rf "$tmp" rm -rf "$tmp"
echo 'PASS: microVM boot, root SSH, shared toolchain, RW cwd/config/creds/skills, writable non-clobbering skill seed, headless browser CLI, host ownership, symlink isolation, duplicate lock, shutdown' echo 'PASS: microVM boot, root SSH, shared toolchain, RW cwd/config/creds/skills, writable non-clobbering skill seed, context-only plugin setup preserved on restart, headless browser CLI, host ownership, symlink isolation, duplicate lock, shutdown'
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Exercise the real setup script against an offline, strict DSH CLI fixture.
set -euo pipefail
setup=$1
dsh=$2
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
export HOME="$tmp/home" DSH_HOME="$tmp/dsh home"
mkdir -p "$HOME" "$DSH_HOME"
profile="$DSH_HOME/profiles/web"
manifest="$profile/package.json"
run() { bash "$setup" "$dsh"; }
calls() { wc -l < "$DSH_HOME/plugin-calls"; }
# Fresh home: upstream initializes Web, and only the requested plugin is added.
run
[[ $(< "$DSH_HOME/plugin-calls") == 'plugin --profile web add dsh-context@latest' ]]
jq -e '.dependencies | keys == ["dsh-context"]' "$manifest"
cp "$manifest" "$tmp/installed.json"
run
[[ $(calls) == 1 ]]
cmp "$manifest" "$tmp/installed.json"
# Existing home: preserve settings, credentials, patches, other profiles/plugins.
printf 'user settings\n' > "$DSH_HOME/settings.yaml"
printf 'fixture credentials, not real\n' > "$DSH_HOME/.credentials.yaml"
printf 'user patch\n' > "$profile/cordis.patch.yml"
mkdir -p "$DSH_HOME/profiles/headless"
printf '{"private":true}\n' > "$DSH_HOME/profiles/headless/package.json"
jq 'del(.dependencies["dsh-context"]) |
.dependencies["user-plugin"] = "1.2.3" |
.dsh.profile.bundles = ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "user-plugin"] |
.custom = {"keep":true}' "$manifest" > "$tmp/existing.json"
cp "$tmp/existing.json" "$manifest"
run
[[ $(calls) == 2 ]]
jq 'del(.dependencies["dsh-context"]) | .dsh.profile.bundles -= ["dsh-context"]' "$manifest" > "$tmp/preserved.json"
cmp "$tmp/existing.json" "$tmp/preserved.json"
[[ $(< "$DSH_HOME/settings.yaml") == 'user settings' ]]
[[ $(< "$DSH_HOME/.credentials.yaml") == 'fixture credentials, not real' ]]
[[ $(< "$profile/cordis.patch.yml") == 'user patch' ]]
[[ $(< "$DSH_HOME/profiles/headless/package.json") == '{"private":true}' ]]
# A selected version is not upgraded on restart, even when installation needs repair.
jq '.dependencies["dsh-context"] = "0.40.0"' "$manifest" > "$tmp/pinned.json"
cp "$tmp/pinned.json" "$manifest"
run
[[ $(calls) == 2 ]]
cmp "$manifest" "$tmp/pinned.json"
rm -rf "$profile/node_modules/dsh-context"
run
[[ $(calls) == 3 && $(tail -1 "$DSH_HOME/plugin-calls") == 'plugin --profile web add dsh-context@0.40.0' ]]
cmp "$manifest" "$tmp/pinned.json"
jq '.dsh.profile.bundles -= ["dsh-context"]' "$manifest" > "$tmp/unregistered.json"
cp "$tmp/unregistered.json" "$manifest"
run
[[ $(calls) == 4 ]]
cmp "$manifest" "$tmp/pinned.json"
# Partial failure must fail startup and be retried, not hidden behind a stamp.
rm -rf "$profile/node_modules/dsh-context"
touch "$DSH_HOME/fail-plugin-install"
if run; then echo 'Accepted a failed plugin install' >&2; exit 1; fi
[[ $(calls) == 5 && ! -e $profile/node_modules/dsh-context/package.json ]]
rm "$DSH_HOME/fail-plugin-install"
run
[[ $(calls) == 6 ]]
cmp "$manifest" "$tmp/pinned.json"
run
[[ $(calls) == 6 ]]
# Corrupt user data is an error, never permission to reset the profile.
printf 'not JSON\n' > "$manifest"
if run; then echo 'Accepted a malformed profile' >&2; exit 1; fi
[[ $(calls) == 6 && $(< "$manifest") == 'not JSON' ]]
echo 'PASS: context-only install, idempotence, profile preservation, selected version, incomplete install repair, failure/retry, malformed profile rejection'
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Required Web plugin, installed only inside the guest's live shared DSH home.
set -euo pipefail
profile="${DSH_HOME:?DSH_HOME must name the shared guest profile home}/profiles/web"
manifest="$profile/package.json"
spec=latest
if [[ -e $manifest ]]; then
# Fail on malformed JSON instead of replacing a user's profile. Keep an
# existing version/path spec when repairing an incomplete installation.
configured=$(jq -r '.dependencies["dsh-context"] // empty' "$manifest")
if [[ -n $configured ]]; then spec=$configured; fi
fi
installed() {
[[ -f $manifest && -f $profile/node_modules/dsh-context/package.json ]] &&
jq -e '.dependencies["dsh-context"] != null and
((.dsh.profile.bundles // []) | index("dsh-context") != null)' "$manifest" >/dev/null
}
if installed; then exit 0; fi
# Let upstream initialize/reconcile the profile; never generate its manifests,
# settings or credentials ourselves. No other plugin is added or updated here.
echo 'Installing dsh-context in the DSH web profile.' >&2
"$1" plugin --profile web add "dsh-context@$spec"
if ! installed; then
echo 'dsh-context installation did not produce an installed Web bundle.' >&2
exit 1
fi
+29
View File
@@ -0,0 +1,29 @@
# Offline CLI fixture only: no npm, network installs, credentials or model calls.
set -euo pipefail
profile="$DSH_HOME/profiles/web"
manifest="$profile/package.json"
if [[ ${1:-} == plugin ]]; then
[[ $# == 5 && $2 == --profile && $3 == web && $4 == add && $5 == dsh-context@* ]]
printf '%s\n' "$*" >> "$DSH_HOME/plugin-calls"
mkdir -p "$profile"
if [[ ! -e $manifest ]]; then
printf '%s\n' '{"dsh":{"profile":{"bundles":["@deepseek-ai/dsh-base","@deepseek-ai/dsh-web-app"]}}}' > "$manifest"
fi
jq --arg spec "${5#dsh-context@}" '.dependencies["dsh-context"] = $spec' "$manifest" > "$manifest.tmp"
mv "$manifest.tmp" "$manifest"
# Simulate a failed install after pnpm has already recorded the dependency.
[[ ! -e $DSH_HOME/fail-plugin-install ]] || exit 42
mkdir -p "$profile/node_modules/dsh-context"
printf '%s\n' '{"name":"dsh-context","version":"0.0.0"}' > "$profile/node_modules/dsh-context/package.json"
jq '.dsh.profile.bundles |= (. + ["dsh-context"] | unique)' "$manifest" > "$manifest.tmp"
mv "$manifest.tmp" "$manifest"
exit 0
fi
[[ ${1:-} == web ]]
# The service must finish plugin setup before starting the Web listener.
[[ -f $profile/node_modules/dsh-context/package.json ]]
jq -e '.dsh.profile.bundles | index("dsh-context") != null' "$manifest" >/dev/null
exec node -e '
require("node:http").createServer((req, res) => res.end("agent-vm-test"))
.listen(3080, "127.0.0.1", () => console.log("http://127.0.0.1:3080/?token=offline-test"));
'
+4 -2
View File
@@ -90,8 +90,10 @@ trap 'exit 143' TERM
# Network is intentionally inherited for API access (not an egress firewall). # Network is intentionally inherited for API access (not an egress firewall).
devices=() devices=()
[[ $AGENT_NETWORK != tap ]] || devices=(--dev-bind /dev/net/tun /dev/net/tun) [[ $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 \ # Map the caller to namespace uid/gid 0 so 9p ownership matches guest root.
--unshare-uts --unshare-cgroup-try --cap-drop ALL --clearenv \ # Host writes still belong to the caller; no host-root identity/capability is gained.
bwrap "${devices[@]}" --die-with-parent --new-session --unshare-user --uid 0 --gid 0 \
--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 \ --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 \ --ro-bind /nix/store /nix/store --proc /proc --dev /dev --dev-bind /dev/kvm /dev/kvm \
--tmpfs /tmp --bind "$state" /state --bind "$project" /workspace \ --tmpfs /tmp --bind "$state" /state --bind "$project" /workspace \
+6
View File
@@ -22,6 +22,8 @@ let
]; ];
text = '' text = ''
export npm_config_cache=/var/cache/dsh/npm export npm_config_cache=/var/cache/dsh/npm
# pnpm's SQLite index needs a local filesystem, not the shared 9p mount.
export pnpm_config_store_dir=/var/cache/dsh/pnpm
# Explicitly rolling upstream, not a pretend-reproducible Nix derivation. # Explicitly rolling upstream, not a pretend-reproducible Nix derivation.
exec npm exec --yes --package=@deepseek-ai/dsh@latest -- ${dshNode} "$@" exec npm exec --yes --package=@deepseek-ai/dsh@latest -- ${dshNode} "$@"
''; '';
@@ -356,6 +358,8 @@ in
cp --update=none --no-preserve=mode \ cp --update=none --no-preserve=mode \
${./skills/playwright-firefox/SKILL.md} \ ${./skills/playwright-firefox/SKILL.md} \
/root/.agents/skills/playwright-firefox/SKILL.md /root/.agents/skills/playwright-firefox/SKILL.md
# Required plugin setup uses the mounted profile, never the host or store.
${pkgs.bash}/bin/bash ${./context.sh} ${cfg.package}/bin/dsh
''; '';
serviceConfig = { serviceConfig = {
User = "root"; User = "root";
@@ -387,6 +391,8 @@ in
) (map (port: "${net.hostAddress}:${toString port}") (lib.range net.webPort net.webPortEnd)) ) (map (port: "${net.hostAddress}:${toString port}") (lib.range net.webPort net.webPortEnd))
) )
); );
# First-time DSH/plugin downloads run in ExecStartPre, not at Nix build time.
TimeoutStartSec = "10min";
Restart = "on-failure"; Restart = "on-failure";
RestartSec = 3; RestartSec = 3;
UMask = "0077"; UMask = "0077";
+22 -7
View File
@@ -5,6 +5,7 @@
}: }:
let let
c = example.nixos.config; c = example.nixos.config;
fakeDsh = pkgs.writeShellScriptBin "dsh" (builtins.readFile ./fake-dsh.sh);
testVM = inputs.self.lib.mkAgentVM { testVM = inputs.self.lib.mkAgentVM {
system = pkgs.stdenv.hostPlatform.system; system = pkgs.stdenv.hostPlatform.system;
project = { project = {
@@ -15,12 +16,7 @@ let
{ {
# Offline infrastructure test. A real DSH startup is tested separately; # Offline infrastructure test. A real DSH startup is tested separately;
# @latest needs the network and is intentionally outside Nix reproducibility. # @latest needs the network and is intentionally outside Nix reproducibility.
agentVM.package = pkgs.writeShellScriptBin "dsh" '' agentVM.package = fakeDsh;
exec ${pkgs.nodejs}/bin/node -e '
require("node:http").createServer((req, res) => res.end("agent-vm-test"))
.listen(3080, "127.0.0.1", () => console.log("http://127.0.0.1:3080/?token=offline-test"));
'
'';
} }
]; ];
}; };
@@ -74,6 +70,10 @@ in
assert c.microvm.storeOnDisk; assert c.microvm.storeOnDisk;
assert c.systemd.services.agent.serviceConfig.User == "root"; assert c.systemd.services.agent.serviceConfig.User == "root";
assert c.systemd.services.agent.serviceConfig.WorkingDirectory == "/workspace"; assert c.systemd.services.agent.serviceConfig.WorkingDirectory == "/workspace";
assert pkgs.lib.hasInfix
(builtins.unsafeDiscardStringContext "${./context.sh} ${c.agentVM.package}/bin/dsh")
c.systemd.services.agent.preStart;
assert c.systemd.services.agent.serviceConfig.TimeoutStartSec == "10min";
assert c.services.openssh.settings.PasswordAuthentication == false; assert c.services.openssh.settings.PasswordAuthentication == false;
assert c.services.openssh.settings.AllowAgentForwarding == false; assert c.services.openssh.settings.AllowAgentForwarding == false;
assert !c.services.xserver.enable; assert !c.services.xserver.enable;
@@ -99,11 +99,26 @@ in
]; ];
} }
'' ''
shellcheck -s bash ${./launch.sh} ${./boot-test.sh} ${./playwright-test.sh} ${./port-test.sh} shellcheck -s bash ${./launch.sh} ${./boot-test.sh} ${./playwright-test.sh} ${./port-test.sh} \
${./context.sh} ${./context-test.sh} ${./fake-dsh.sh}
bash -n ${./launch.sh} bash -n ${./launch.sh}
touch "$out" touch "$out"
''; '';
context =
pkgs.runCommand "agent-dsh-context-check"
{
nativeBuildInputs = [
pkgs.bash
pkgs.coreutils
pkgs.jq
];
}
''
bash ${./context-test.sh} ${./context.sh} ${fakeDsh}/bin/dsh
touch "$out"
'';
playwright = playwright =
pkgs.runCommand "agent-playwright-firefox-check" pkgs.runCommand "agent-playwright-firefox-check"
{ {
+12
View File
@@ -1,5 +1,6 @@
{ {
config, config,
inputs,
lib, lib,
pkgs, pkgs,
... ...
@@ -7,6 +8,11 @@
let let
c = import ./colors.nix; c = import ./colors.nix;
# Tauon follows the fast-moving pin without overlaying the system libraries.
latest = import inputs.nixpkgs-latest {
inherit (pkgs.stdenv.hostPlatform) system;
config = pkgs.config;
};
elementNightly = import ./element-nightly.nix { inherit pkgs; }; elementNightly = import ./element-nightly.nix { inherit pkgs; };
browserAddon = pkgs.fetchurl { browserAddon = pkgs.fetchurl {
name = "keepassxc-browser-1.10.3.xpi"; name = "keepassxc-browser-1.10.3.xpi";
@@ -106,11 +112,17 @@ in
thunderbird thunderbird
file-roller file-roller
imv imv
latest.tauon
# Electron does not reliably detect a keyring under Hyprland. Use the # Electron does not reliably detect a keyring under Hyprland. Use the
# KeePassXC Secret Service explicitly; never fall back to basic_text. # KeePassXC Secret Service explicitly; never fall back to basic_text.
elementNightly elementNightly
slack slack
]; ];
# Fix the pinned package's stale launcher command, preserving its metadata.
xdg.dataFile."applications/tauonmb.desktop".source = pkgs.runCommand "tauonmb.desktop" { } ''
substitute ${latest.tauon}/share/applications/tauonmb.desktop "$out" \
--replace-quiet 'Exec=tauonmb ' 'Exec=${lib.getExe latest.tauon} '
'';
programs.mpv = { programs.mpv = {
enable = true; enable = true;
config = { config = {
+1 -1
View File
@@ -10,7 +10,7 @@
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
}; };
# Fast-moving developer tools can advance ahead of the tested channel. # Fast-moving standalone apps/tools can advance ahead of the tested channel.
# flake.lock still records the exact resolved snapshot. # flake.lock still records the exact resolved snapshot.
nixpkgs-latest.url = "github:NixOS/nixpkgs/master"; nixpkgs-latest.url = "github:NixOS/nixpkgs/master";
+13 -5
View File
@@ -1,5 +1,4 @@
{ {
config,
inputs, inputs,
lib, lib,
pkgs, pkgs,
@@ -8,14 +7,14 @@
let let
latest = import inputs.nixpkgs-latest { latest = import inputs.nixpkgs-latest {
inherit (pkgs.stdenv.hostPlatform) system; inherit (pkgs.stdenv.hostPlatform) system;
config = pkgs.config; inherit (pkgs) config;
}; };
in in
{ {
# NetworkManager owns local DNS on workstations. With dhcpcd (EC2), # Keep resolvectl available on both hosts. NixOS wires NetworkManager and
# NixOS wires resolvconf to resolved. Preserve both hosts' existing behavior. # /etc/resolv.conf to resolved; DHCP/VPNs still supply the upstream DNS.
services.resolved = { services.resolved = {
enable = lib.mkDefault (!config.networking.networkmanager.enable); enable = true;
settings.Resolve = { settings.Resolve = {
LLMNR = false; LLMNR = false;
MulticastDNS = false; MulticastDNS = false;
@@ -23,6 +22,15 @@ in
}; };
}; };
# The NixOS module enables resolved at boot. Retry exits without a start limit.
systemd.services.systemd-resolved = {
unitConfig.StartLimitIntervalSec = 0;
serviceConfig = {
Restart = "always";
RestartSec = "5s";
};
};
# Local, opt-in SOCKS client only. Keep the daemon on the system package pin. # Local, opt-in SOCKS client only. Keep the daemon on the system package pin.
# No relay/exit, control listener, transparent proxy or host DNS changes. # No relay/exit, control listener, transparent proxy or host DNS changes.
services.tor = { services.tor = {
+30 -3
View File
@@ -84,11 +84,38 @@ let
{ {
assertion = assertion =
config.networking.networkmanager.enable config.networking.networkmanager.enable
&& !config.services.resolved.enable && config.networking.networkmanager.dns == "systemd-resolved"
&& builtins.elem (lib.getName pkgs.networkmanager-openvpn) ( && builtins.elem (lib.getName pkgs.networkmanager-openvpn) (
map lib.getName config.networking.networkmanager.plugins map lib.getName config.networking.networkmanager.plugins
); );
message = "Keep NetworkManager/DNS and provide its OpenVPN integration."; message = "Keep NetworkManager with resolved DNS and its OpenVPN integration.";
}
{
assertion =
lib.all
(
c:
let
unit = c.systemd.services.systemd-resolved;
in
c.services.resolved.enable
&& builtins.elem "sysinit.target" unit.wantedBy
&& builtins.elem "dbus-org.freedesktop.resolve1.service" unit.aliases
&& unit.serviceConfig.Restart == "always"
&& unit.serviceConfig.RestartSec == "5s"
&& unit.unitConfig.StartLimitIntervalSec == 0
&& !c.networking.resolvconf.enable
&& c.networking.resolvconf.package == c.systemd.package
&& c.environment.etc."resolv.conf".source == "/run/systemd/resolve/stub-resolv.conf"
&& c.services.resolved.settings.Resolve.DNS == [ ]
&& !c.services.resolved.settings.Resolve.LLMNR
&& !c.services.resolved.settings.Resolve.MulticastDNS
)
[
config
ec2Config
];
message = "Both hosts need boot-enabled, restarting resolved with D-Bus/stub DNS integration, without hard-coded DNS servers.";
} }
{ {
assertion = assertion =
@@ -232,7 +259,7 @@ pkgs.runCommand "physical-config-check" { } ''
test -f "$sessions/wayland-sessions/hyprland-uwsm.desktop" test -f "$sessions/wayland-sessions/hyprland-uwsm.desktop"
test ! -e "$sessions/wayland-sessions/hyprland.desktop" test ! -e "$sessions/wayland-sessions/hyprland.desktop"
for tool in wg wg-quick openvpn iperf3 nmap traceroute whois mtr dig tcpdump ethtool nc socat \ for tool in wg wg-quick openvpn iperf3 nmap traceroute whois mtr dig tcpdump ethtool nc socat \
fping drill torsocks proxychains4 tor nm-connection-editor; do fping drill torsocks proxychains4 tor nm-connection-editor resolvectl; do
test -x "${config.system.path}/bin/$tool" test -x "${config.system.path}/bin/$tool"
done done
# Validate the exact generated torrc offline without touching live Tor state. # Validate the exact generated torrc offline without touching live Tor state.
+8
View File
@@ -31,6 +31,14 @@ unique `-s=NAME` on every command, its own `.playwright-cli/NAME/` artifacts, an
close only its own session—never `close-all` or `kill-all`. No host browser close only its own session—never `close-all` or `kill-all`. No host browser
profiles or display sockets are imported. profiles or display sockets are imported.
The guest installs **only `dsh-context`** into the shared `web` profile before the
first Web startup, providing the **Context** tab and **`/context`** command.
Existing versions/settings and other user-installed plugins are preserved;
restarts do not upgrade it. Installation needs registry access; failures appear
in `journalctl -u agent -b` and block Web startup. To update it explicitly, use
`dsh plugin --profile web update dsh-context@latest` inside the guest while
`agent.service` is stopped, then start the service and request a fresh login URL.
DSH resolves `@deepseek-ai/dsh@latest` inside the VM on startup. Nix packages, DSH resolves `@deepseek-ai/dsh@latest` inside the VM on startup. Nix packages,
including the CLI and its matching Firefox, follow the rolling Nixpkgs input: including the CLI and its matching Firefox, follow the rolling Nixpkgs input:
`nix flake update`, then restart the VM. Merge changes to an already-seeded skill `nix flake update`, then restart the VM. Merge changes to an already-seeded skill