Compare commits
4
Commits
a58dd24efe
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98126c4cfa | ||
|
|
8b6f90dad9 | ||
|
|
40d7de6b7f | ||
|
|
15878fd3b7 |
+55
-37
@@ -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
|
||||
600 as DSH requires. The launcher never reads the credentials into Nix, copies
|
||||
them to the store, or replaces your settings/skills with generated content.
|
||||
Before starting the Web UI, the guest installs **only `dsh-context`** into the
|
||||
shared `web` profile if missing (details below). DSH/pnpm manages that profile's
|
||||
manifest, lockfile and dependencies; existing settings and other profiles stay intact.
|
||||
The one bundled `playwright-firefox` skill is seeded into the actual shared skills
|
||||
directory only if its `SKILL.md` is absent. It is a normal writable file (0600),
|
||||
not a store symlink; existing files and user edits are never overwritten.
|
||||
|
||||
Root inside the guest is **not host root**. 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.
|
||||
Root inside the guest is **not host root**. Bubblewrap maps the launching user's
|
||||
UID/GID to namespace `0:0`, so 9p ownership matches guest root and pnpm's atomic
|
||||
saves can preserve it. All capabilities remain dropped. QEMU/9p still writes as
|
||||
the launching host user, so newly created project files belong to you. The whole
|
||||
DSH directory is mounted instead of individual credential files so atomic
|
||||
rename-based saves work.
|
||||
DSH retains each project's real cwd identity rather than confusing every project's
|
||||
sessions with a single `/workspace` identity. Do not run host DSH and several
|
||||
VMs concurrently against the same mutable profile: package-fallback links and
|
||||
@@ -82,7 +88,7 @@ profile changes can race. Separate `DSH_HOME` values provide independent profile
|
||||
|
||||
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,
|
||||
control socket, and a 4 GiB sparse npm/pnpm-cache disk. Guest root, guest Nix writes,
|
||||
processes and other unshared state are otherwise ephemeral. This is **not** a
|
||||
push/pull/snapshot workflow; edits immediately affect the mounted host files.
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
- **Plugin:** the guest installs `dsh-context@latest` into the `web` profile once.
|
||||
Normal restarts keep the installed version; updates are explicit and targeted
|
||||
to `dsh-context` below. This is mutable DSH/pnpm state, not a Nix-pinned package.
|
||||
No other third-party plugin is added or automatically upgraded.
|
||||
- **Workstation:** the root `/etc/nix` flake now also follows `nixos-unstable` and
|
||||
Home Manager `master`; CLI tools still use Nixpkgs `master`. Its existing daily
|
||||
updater refreshes those inputs and stages the tested result for next boot.
|
||||
@@ -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
|
||||
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
|
||||
of compatibility with tomorrow's `latest`. These are actual DSH plugins, not
|
||||
OpenCode plugins relabeled as DeepSeek plugins. GitHub stars checked 2026-09-06:
|
||||
[**dsh-context**](https://github.com/bowenliang123/dsh-context) is the only
|
||||
third-party plugin added by this setup. It provides the **Context** tab and
|
||||
**`/context`** command for context composition, token use and history. The guest's
|
||||
`agent.service` runs `dsh plugin --profile web add dsh-context@latest` before its
|
||||
first Web startup; no manual install or host package install is needed. Update
|
||||
the helper input and stop/restart existing VMs to pick up this setup change.
|
||||
|
||||
| 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 |
|
||||
The installed plugin and bundle registration live in the writable
|
||||
`$DSH_HOME/profiles/web`, alongside the profile's pnpm lockfile. Already-installed
|
||||
versions are left alone. An incomplete install (missing package or bundle
|
||||
registration) is retried with the profile's existing dependency spec, if any.
|
||||
Removing the required plugin causes it to be added again at the next service
|
||||
start. Existing user-installed plugins are **not removed**, and no settings,
|
||||
credentials, profile patches or other profiles are replaced. Use a separate
|
||||
`DSH_HOME` for an independent profile; don't run concurrent writers on one profile.
|
||||
|
||||
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:
|
||||
First installation needs registry access. Failure blocks Web startup rather than
|
||||
silently omitting the plugin; inspect `journalctl -u agent -b` inside the VM.
|
||||
The DSH wrapper puts pnpm's store on `/var/cache/dsh/pnpm`: its SQLite index needs
|
||||
the guest's local cache disk, not the shared 9p filesystem. Installed packages and
|
||||
profile files remain shared. No build-script approval policy is relaxed.
|
||||
To explicitly update **only** this plugin, inside the guest:
|
||||
|
||||
```sh
|
||||
nix run .#agent -- ssh
|
||||
# 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
|
||||
systemctl stop agent
|
||||
dsh plugin --profile web update dsh-context@latest
|
||||
systemctl start agent
|
||||
exit
|
||||
nix run .#agent -- url # restart creates a fresh browser launch token
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
DSH and this plugin remain developer-preview code, not an audited combination or
|
||||
a compatibility guarantee for tomorrow's `latest`. The Firefox CLI + skill needs
|
||||
no additional plugin or external MCP server.
|
||||
|
||||
## Sandbox boundary and limitations
|
||||
|
||||
@@ -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 build path:/etc/nix/agent-vm#checks.x86_64-linux.config \
|
||||
path:/etc/nix/agent-vm#checks.x86_64-linux.shell \
|
||||
path:/etc/nix/agent-vm#checks.x86_64-linux.context \
|
||||
path:/etc/nix/agent-vm#checks.x86_64-linux.playwright --no-link
|
||||
# Actual offline microVM boot/mount/SSH/browser test, with a fake harness (no API calls):
|
||||
nix build path:/etc/nix/agent-vm#checks.x86_64-linux.boot --no-link
|
||||
@@ -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 context check uses a strict offline DSH fixture to check context-only install,
|
||||
restart idempotence, preservation of existing profile data/version selections,
|
||||
incomplete-install repair, failure/retry and malformed-profile rejection. The boot
|
||||
check also verifies setup before Web startup and writable host-owned plugin state
|
||||
that survives a service restart without reinstalling.
|
||||
The Playwright check launches two real Firefox instances concurrently against a
|
||||
local HTTP fixture, checks headless/default browser selection, separate cookies,
|
||||
DOM and localStorage, screenshots, close-one/keep-one behavior and profile expiry.
|
||||
@@ -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
|
||||
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.
|
||||
DSH stayed running without restarts. That core-only check used no real API
|
||||
credentials, model requests, TAP network provisioning or host activation.
|
||||
The Firefox two-session regression also passed natively and in the real headless
|
||||
microVM, including screenshot generation and writable, non-clobbering skill seeding.
|
||||
A separate live VM check with a disposable DSH home installed only `dsh-context`,
|
||||
verified its composed bundle and authenticated HTTP **200**, then confirmed a
|
||||
service restart left the manifest/lockfile unchanged without reinstalling. No
|
||||
real credentials or model calls were used; the test VM and profile were removed.
|
||||
|
||||
Research used the **new official wiki**, plus upstream sources:
|
||||
|
||||
@@ -392,5 +410,5 @@ Research used the **new official wiki**, plus upstream sources:
|
||||
- [Official Playwright CLI](https://github.com/microsoft/playwright-cli), its
|
||||
[session-management reference](https://github.com/microsoft/playwright-cli/blob/main/skills/playwright-cli/references/session-management.md),
|
||||
and the CLI/core implementation shipped in the locked Nixpkgs Playwright package.
|
||||
- [Awesome DSH plugins](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
|
||||
plus the individual repositories above; no third-party plugin was installed.
|
||||
- [dsh-context](https://github.com/bowenliang123/dsh-context) — upstream install,
|
||||
targeted update and context UI documentation.
|
||||
|
||||
@@ -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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
@@ -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 **3080–3100**;
|
||||
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
|
||||
`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.
|
||||
|
||||
### 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
|
||||
|
||||
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
|
||||
|
||||
**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.
|
||||
|
||||
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
@@ -47,14 +47,24 @@ fi
|
||||
[[ $(< "$DSH_AGENTS_HOME/skills/test.md") == shared ]]
|
||||
[[ $(stat -c %u changed) == "$(id -u)" ]]
|
||||
"$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'
|
||||
skill="$DSH_AGENTS_HOME/skills/playwright-firefox/SKILL.md"
|
||||
grep -q '^name: playwright-firefox$' "$skill"
|
||||
[[ ! -L $skill && -w $skill && $(stat -c %a "$skill") == 600 ]]
|
||||
[[ $(stat -c %u "$skill") == "$(id -u)" ]]
|
||||
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'
|
||||
grep -q '^user customization$' "$skill"
|
||||
[[ $(wc -l < "$DSH_HOME/plugin-calls") == 1 ]]
|
||||
cmp "$manifest" "$tmp/installed.json"
|
||||
if [[ -n $browser_test ]]; then
|
||||
cp "$browser_test" ./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"
|
||||
trap - EXIT
|
||||
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'
|
||||
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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
@@ -90,8 +90,10 @@ trap 'exit 143' TERM
|
||||
# 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 \
|
||||
# Map the caller to namespace uid/gid 0 so 9p ownership matches guest root.
|
||||
# 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 \
|
||||
--ro-bind /nix/store /nix/store --proc /proc --dev /dev --dev-bind /dev/kvm /dev/kvm \
|
||||
--tmpfs /tmp --bind "$state" /state --bind "$project" /workspace \
|
||||
|
||||
@@ -22,6 +22,8 @@ let
|
||||
];
|
||||
text = ''
|
||||
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.
|
||||
exec npm exec --yes --package=@deepseek-ai/dsh@latest -- ${dshNode} "$@"
|
||||
'';
|
||||
@@ -356,6 +358,8 @@ in
|
||||
cp --update=none --no-preserve=mode \
|
||||
${./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 = {
|
||||
User = "root";
|
||||
@@ -387,6 +391,8 @@ in
|
||||
) (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";
|
||||
RestartSec = 3;
|
||||
UMask = "0077";
|
||||
|
||||
+22
-7
@@ -5,6 +5,7 @@
|
||||
}:
|
||||
let
|
||||
c = example.nixos.config;
|
||||
fakeDsh = pkgs.writeShellScriptBin "dsh" (builtins.readFile ./fake-dsh.sh);
|
||||
testVM = inputs.self.lib.mkAgentVM {
|
||||
system = pkgs.stdenv.hostPlatform.system;
|
||||
project = {
|
||||
@@ -15,12 +16,7 @@ let
|
||||
{
|
||||
# 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" ''
|
||||
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"));
|
||||
'
|
||||
'';
|
||||
agentVM.package = fakeDsh;
|
||||
}
|
||||
];
|
||||
};
|
||||
@@ -74,6 +70,10 @@ in
|
||||
assert c.microvm.storeOnDisk;
|
||||
assert c.systemd.services.agent.serviceConfig.User == "root";
|
||||
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.AllowAgentForwarding == false;
|
||||
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}
|
||||
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 =
|
||||
pkgs.runCommand "agent-playwright-firefox-check"
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
config,
|
||||
inputs,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
@@ -7,6 +8,11 @@
|
||||
|
||||
let
|
||||
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; };
|
||||
browserAddon = pkgs.fetchurl {
|
||||
name = "keepassxc-browser-1.10.3.xpi";
|
||||
@@ -106,11 +112,17 @@ in
|
||||
thunderbird
|
||||
file-roller
|
||||
imv
|
||||
latest.tauon
|
||||
# Electron does not reliably detect a keyring under Hyprland. Use the
|
||||
# KeePassXC Secret Service explicitly; never fall back to basic_text.
|
||||
elementNightly
|
||||
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 = {
|
||||
enable = true;
|
||||
config = {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
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.
|
||||
nixpkgs-latest.url = "github:NixOS/nixpkgs/master";
|
||||
|
||||
|
||||
+13
-5
@@ -1,5 +1,4 @@
|
||||
{
|
||||
config,
|
||||
inputs,
|
||||
lib,
|
||||
pkgs,
|
||||
@@ -8,14 +7,14 @@
|
||||
let
|
||||
latest = import inputs.nixpkgs-latest {
|
||||
inherit (pkgs.stdenv.hostPlatform) system;
|
||||
config = pkgs.config;
|
||||
inherit (pkgs) config;
|
||||
};
|
||||
in
|
||||
{
|
||||
# NetworkManager owns local DNS on workstations. With dhcpcd (EC2),
|
||||
# NixOS wires resolvconf to resolved. Preserve both hosts' existing behavior.
|
||||
# Keep resolvectl available on both hosts. NixOS wires NetworkManager and
|
||||
# /etc/resolv.conf to resolved; DHCP/VPNs still supply the upstream DNS.
|
||||
services.resolved = {
|
||||
enable = lib.mkDefault (!config.networking.networkmanager.enable);
|
||||
enable = true;
|
||||
settings.Resolve = {
|
||||
LLMNR = 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.
|
||||
# No relay/exit, control listener, transparent proxy or host DNS changes.
|
||||
services.tor = {
|
||||
|
||||
+30
-3
@@ -84,11 +84,38 @@ let
|
||||
{
|
||||
assertion =
|
||||
config.networking.networkmanager.enable
|
||||
&& !config.services.resolved.enable
|
||||
&& config.networking.networkmanager.dns == "systemd-resolved"
|
||||
&& builtins.elem (lib.getName pkgs.networkmanager-openvpn) (
|
||||
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 =
|
||||
@@ -232,7 +259,7 @@ pkgs.runCommand "physical-config-check" { } ''
|
||||
test -f "$sessions/wayland-sessions/hyprland-uwsm.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 \
|
||||
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"
|
||||
done
|
||||
# Validate the exact generated torrc offline without touching live Tor state.
|
||||
|
||||
@@ -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
|
||||
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,
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user