diff --git a/agent-vm/boot-test.sh b/agent-vm/boot-test.sh index 7064fc3..48c5e71 100644 --- a/agent-vm/boot-test.sh +++ b/agent-vm/boot-test.sh @@ -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' diff --git a/agent-vm/context-test.sh b/agent-vm/context-test.sh new file mode 100644 index 0000000..2eb8b60 --- /dev/null +++ b/agent-vm/context-test.sh @@ -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' diff --git a/agent-vm/context.sh b/agent-vm/context.sh new file mode 100644 index 0000000..6992f8c --- /dev/null +++ b/agent-vm/context.sh @@ -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 diff --git a/agent-vm/fake-dsh.sh b/agent-vm/fake-dsh.sh new file mode 100644 index 0000000..3678d89 --- /dev/null +++ b/agent-vm/fake-dsh.sh @@ -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")); +' diff --git a/agent-vm/launch.sh b/agent-vm/launch.sh index 3aadab0..f95c48a 100644 --- a/agent-vm/launch.sh +++ b/agent-vm/launch.sh @@ -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 \ diff --git a/agent-vm/module.nix b/agent-vm/module.nix index b76496f..53645c6 100644 --- a/agent-vm/module.nix +++ b/agent-vm/module.nix @@ -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"; diff --git a/agent-vm/tests.nix b/agent-vm/tests.nix index 4f04166..a8af33a 100644 --- a/agent-vm/tests.nix +++ b/agent-vm/tests.nix @@ -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" {